|
9895
|
446
|
26
|
2026-05-08T13:42:50.268022+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247770268_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
doSearch
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/1
Previous Occurrence
Next Occurrence
Filter Search Results...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"bounds":{"left":0.10472074,"top":0.20430966,"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":"AXCheckBox","text":"Search History","depth":3,"bounds":{"left":0.11735372,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"doSearch","depth":4,"bounds":{"left":0.12832446,"top":0.20351157,"width":0.043882977,"height":0.015961692},"on_screen":true,"value":"doSearch","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.18118352,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"bounds":{"left":0.19115691,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"bounds":{"left":0.19980054,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"bounds":{"left":0.20844415,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"on_screen":false,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Preserve case","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1/1","depth":4,"bounds":{"left":0.22207446,"top":0.20271349,"width":0.025598405,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"bounds":{"left":0.24767287,"top":0.2019154,"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":"Next Occurrence","depth":4,"bounds":{"left":0.25631648,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter Search Results","depth":4,"bounds":{"left":0.2649601,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-5215575906932910908
|
-2472462767135405622
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
doSearch
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/1
Previous Occurrence
Next Occurrence
Filter Search Results...
|
9893
|
NULL
|
NULL
|
NULL
|
|
9894
|
445
|
18
|
2026-05-08T13:42:45.928050+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247765928_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
doSearch
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/1
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"doSearch","depth":4,"on_screen":true,"value":"doSearch","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"on_screen":false,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Preserve case","depth":3,"bounds":{"left":0.0,"top":0.0,"width":0.015277778,"height":0.024444444},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1/1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Occurrence","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter Search Results","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Window, Multiple Cursors","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXLink","text":"Click to highlight","depth":4,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":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}]...
|
-3419229447380999607
|
-465194999110035353
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
doSearch
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/1
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
...
|
9892
|
NULL
|
NULL
|
NULL
|
|
9893
|
446
|
25
|
2026-05-08T13:42:37.423764+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247757423_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
doSearch
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/1
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"bounds":{"left":0.10472074,"top":0.20430966,"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":"AXCheckBox","text":"Search History","depth":3,"bounds":{"left":0.11735372,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"doSearch","depth":4,"bounds":{"left":0.12832446,"top":0.20351157,"width":0.043882977,"height":0.015961692},"on_screen":true,"value":"doSearch","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.18118352,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"bounds":{"left":0.19115691,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"bounds":{"left":0.19980054,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"bounds":{"left":0.20844415,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"on_screen":false,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Preserve case","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1/1","depth":4,"bounds":{"left":0.22207446,"top":0.20271349,"width":0.025598405,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"bounds":{"left":0.24767287,"top":0.2019154,"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":"Next Occurrence","depth":4,"bounds":{"left":0.25631648,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Filter Search Results","depth":4,"bounds":{"left":0.2649601,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Open in Window, Multiple Cursors","depth":4,"bounds":{"left":0.27360374,"top":0.2019154,"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":"AXLink","text":"Click to highlight","depth":4,"on_screen":false,"role_description":"link","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Close","depth":4,"bounds":{"left":0.34408244,"top":0.2019154,"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":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.2330407,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.2330407,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.2330407,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.2330407,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.2330407,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.23144454,"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.35006648,"top":0.23144454,"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\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-3419229447380999607
|
-465194999110035353
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
doSearch
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/1
Previous Occurrence
Next Occurrence
Filter Search Results
Open in Window, Multiple Cursors
Click to highlight
Close
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9892
|
445
|
17
|
2026-05-08T13:42:16.980831+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247736980_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
doSearch
New Line
Match Case
Words
Regex...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Search History","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"doSearch","depth":4,"on_screen":true,"value":"doSearch","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
5948151489553543169
|
-2904790704781309494
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
doSearch
New Line
Match Case
Words
Regex...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9891
|
446
|
24
|
2026-05-08T13:42:16.124605+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247736124_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
doSearch
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/1
Previous Occurrence
Next Occurrence
Filter Search Results...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Show Replace Field","depth":4,"bounds":{"left":0.10472074,"top":0.20430966,"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":"AXCheckBox","text":"Search History","depth":3,"bounds":{"left":0.11735372,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"doSearch","depth":4,"bounds":{"left":0.12832446,"top":0.20351157,"width":0.043882977,"height":0.015961692},"on_screen":true,"value":"doSearch","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.18118352,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Match Case","depth":3,"bounds":{"left":0.19115691,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Words","depth":3,"bounds":{"left":0.19980054,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Regex","depth":3,"bounds":{"left":0.20844415,"top":0.20351157,"width":0.00731383,"height":0.017557861},"on_screen":true,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Replace History","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextField","text":"Replace","depth":4,"on_screen":false,"role_description":"text field","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"New Line","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXCheckBox","text":"Preserve case","depth":3,"bounds":{"left":0.27027926,"top":1.0,"width":0.00731383,"height":0.0},"on_screen":false,"role_description":"checkbox","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"1/1","depth":4,"bounds":{"left":0.22207446,"top":0.20271349,"width":0.025598405,"height":0.017557861},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Occurrence","depth":4,"bounds":{"left":0.24767287,"top":0.2019154,"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":"Next Occurrence","depth":4,"bounds":{"left":0.25631648,"top":0.2019154,"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":"Filter Search Results","depth":4,"bounds":{"left":0.2649601,"top":0.2019154,"width":0.008643617,"height":0.01915403},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-5215575906932910908
|
-2472462767135405622
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Show Replace Field
Search History
doSearch
New Line
Match Case
Words
Regex
Replace History
Replace
New Line
Preserve case
1/1
Previous Occurrence
Next Occurrence
Filter Search Results...
|
9889
|
NULL
|
NULL
|
NULL
|
|
9885
|
446
|
20
|
2026-05-08T13:41:13.613762+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247673613_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.19952115,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.19952115,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.19952115,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.19952115,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.19952115,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.19792499,"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.35006648,"top":0.19792499,"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\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-3175715562603377888
|
-465054261620631449
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
9883
|
NULL
|
NULL
|
NULL
|
|
9884
|
445
|
14
|
2026-05-08T13:41:12.622292+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247672622_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"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}]...
|
-3175715562603377888
|
-465054261620631449
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
9882
|
NULL
|
NULL
|
NULL
|
|
9881
|
446
|
18
|
2026-05-08T13:40:33.194485+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247633194_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.19952115,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.19952115,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.19952115,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.19952115,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.19952115,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.19792499,"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.35006648,"top":0.19792499,"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\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-3175715562603377888
|
-465054261620631449
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
9880
|
NULL
|
NULL
|
NULL
|
|
9879
|
445
|
12
|
2026-05-08T13:40:18.175222+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247618175_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"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}]...
|
-3175715562603377888
|
-465054261620631449
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
9877
|
NULL
|
NULL
|
NULL
|
|
9878
|
446
|
16
|
2026-05-08T13:40:16.624083+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247616624_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.19952115,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.19952115,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.19952115,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.19952115,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.19952115,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.19792499,"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.35006648,"top":0.19792499,"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\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-3175715562603377888
|
-465054261620631449
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
9876
|
NULL
|
NULL
|
NULL
|
|
9810
|
444
|
11
|
2026-05-08T13:34:45.328383+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247285328_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.09736632,"width":0.5728058,"height":0.8818835},"on_screen":true,"lines":[{"char_start":207,"char_count":30,"bounds":{"left":0.37632978,"top":0.0,"width":0.07513298,"height":0.014365523}},{"char_start":237,"char_count":36,"bounds":{"left":0.37632978,"top":0.0,"width":0.09075798,"height":0.014365523}},{"char_start":273,"char_count":32,"bounds":{"left":0.37632978,"top":0.0,"width":0.080119684,"height":0.014365523}},{"char_start":305,"char_count":79,"bounds":{"left":0.37632978,"top":0.0,"width":0.20212767,"height":0.014365523}},{"char_start":384,"char_count":18,"bounds":{"left":0.37632978,"top":0.0,"width":0.043882977,"height":0.014365523}},{"char_start":402,"char_count":21,"bounds":{"left":0.37632978,"top":0.0,"width":0.051861703,"height":0.014365523}},{"char_start":423,"char_count":48,"bounds":{"left":0.37632978,"top":0.008778931,"width":0.12167553,"height":0.014365523}},{"char_start":471,"char_count":72,"bounds":{"left":0.37632978,"top":0.026336791,"width":0.18384309,"height":0.014365523}},{"char_start":543,"char_count":40,"bounds":{"left":0.37632978,"top":0.043894652,"width":0.10106383,"height":0.014365523}},{"char_start":583,"char_count":41,"bounds":{"left":0.37632978,"top":0.061452515,"width":0.10372341,"height":0.014365523}},{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.079010375,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.096568234,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.11412609,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.13168396,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.14924182,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.16679968,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.18435754,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.2019154,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.21947326,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.23703113,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.254589,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.27214685,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.2897047,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.30726257,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-3175715562603377888
|
-465054261620631449
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
9809
|
NULL
|
NULL
|
NULL
|
|
9809
|
444
|
10
|
2026-05-08T13:34:34.823745+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247274823_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6337657672782761712
|
-3048904810292590134
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9808
|
443
|
5
|
2026-05-08T13:34:18.917723+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247258917_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":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}]...
|
-3175715562603377888
|
-465054261620631449
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9807
|
444
|
9
|
2026-05-08T13:34:16.225399+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247256225_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-3175715562603377888
|
-465054261620631449
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata = $data;
$this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);
$this->logCrmEngagementManipulation(
self::ACTION_UPDATE,
['crmId...
|
9806
|
NULL
|
NULL
|
NULL
|
|
9806
|
444
|
8
|
2026-05-08T13:34:12.176289+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247252176_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6337657672782761712
|
-3048904810292590134
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9805
|
444
|
7
|
2026-05-08T13:34:05.546798+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247245546_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
9
6
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
// $name,
// $this->getContactFields()
// );
// $hsContacts = $this->client->getPaginatedData($payload, 'contact');
// return null;
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata =...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.31349733,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9","depth":4,"bounds":{"left":0.3231383,"top":0.17478053,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"6","depth":4,"bounds":{"left":0.3331117,"top":0.17478053,"width":0.007978723,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n// $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n// $name,\n// $this->getContactFields()\n// );\n// $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n// return null;\n\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n// $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n// $name,\n// $this->getContactFields()\n// );\n// $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n// return null;\n\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
1468664438918297136
|
-465546842829875097
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
9
6
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
// $name,
// $this->getContactFields()
// );
// $hsContacts = $this->client->getPaginatedData($payload, 'contact');
// return null;
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata =...
|
9804
|
NULL
|
NULL
|
NULL
|
|
9804
|
444
|
6
|
2026-05-08T13:33:56.782361+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247236782_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-7349965412982217516
|
-8168391185862906426
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
PhostormVIewINavigareCodeLaravelKeractorJOOISWindowFV faVsco.js°9 JY-20725-handle-HS-search-rate-limitProiect©) JiminnyDebugCommano.png© BatchSyncCollector.f G DeleteCrmEntityTrait.phpo kematcnactiviyoncrmobjectbetach.org© BatchSyncRedisServ© CheckAndRetryRemoteMatch.php© MatchacuvitycrmData.ongc clientonp© ClosedDealStagesSe© PaginationState.phgC)MatchermData.phg@ DealFieldsService.phHubspotPaqinationservice.ono) Paqinationconria.onoc)Decorateacuiviv.one© FieldDefinitions.phcclass Service extends BaseService 1mplementsm A7 A9 ×6 ^use syncrielosiralcc) FieldTvpeconverter.use upporcunlcysynciralc@ HubspotClientinterfa(C) HubspotTokenManacC) PavloadBuilder.ohpprivate const 1nt ENGAGEMENT_BODY MAX_LENGTH = 655367(C) RemotecrmObiectMaC) ResponseNormalize.(c) Service.ondorivate const strino LOG DATE FORMAT &"Y-m-dHH1.S'*© SyncFieldAction.php1usage(C) SvncRelatedActivitviorivate const int BATCH UPDATE LIMTT = 100-€ WebhookSvncBatch!v MintearationAoo1 usageprivate const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';>IM Acceccordlusage>D Api>D Config• Mотоprivate const int TEN_SECONDLY_ROLLING_LIMIT = 10;M Silterc1 usageprivate const string TYPE_NOTE = 'NOTE':D JobsD ProspectSearchStratm CorvicoTraiteprivate const string TYPE_MEETING = 'MEETING':© DataClient.php= custom.log x= laravel.logA SF [jiminny@localhost]4 HS_local [jiminny@localhost]# console [PKobJA console leu)A console [STAGING]accept-encoding"],"access-control-allow-credentials":["false"]."server-timing": ["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",cfr;desc=|"9f80deb8e7c6dc3a-IAD\""]"x-content-type-options":["nosniff"],M X19 A V100% 12Fri 8 May 16:33:59AskJiminnyReportActivityServiceTest vCascadeHubspot Rate LimitingHubSpot Rate Limit ReInvestigating Rate LinNew CascadeHubspot erm call Regetinscanceo)-geteuencoprequesct rust, /cauts/searcnysearchCallByRecordingURLToken() (line ~1752)createNote()saveTranscriptionSummaryAsNote() (line ~1896)updateEngagement()attachSummaryToActivity() (line ~1938)getEngagementData()buildMetadataForSummaryUpdate() (line ~1960)+0..SEARCHBURSTBURSTBURSTTdgeaslleslldlaa"x-hubspot-correlation-id":("019e02d0-6fd8-7812-bdba-885b7ccb3ee3"]."set-LookLe":_ct._om=s1urcaugxvrzksopacronzvyknzinubzovniabelcmoy-1//81050/0-[IP_ADDRESS]-r1.299cUkxIgeszro2gDBTWM00.Ut207-May-26 14:51:15 GMT: domain=.hubapi.com;Http0nly: Secure: SameSite=None"],"керогс-10":"1"endpornts"."Lnlmuos:valVa.nelcloudrLare. com neDontVvLrSENYALSy PorYmSyOSUnXYE-4SCKYKORSDSWHSMO-04 20Y0X 46743 XVDHMSK"max age":604800-"],"success_fraction\":0.01,"max ade":604800-""Serven"•["cloudflare"1}} {"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab","tnace id"."c7ab8365-903f-46d4-9403-0e5b551e3545"}getPaginatedData($payload, 'deals')Servicetraits/writecrmtratt.onogetClient()->getEngagementData()getClient()->updateEngagement(,getDealsInBulk() (line ~2003)Method contextmaryToActivityo line ~120)upsertActivityo) (line ~172)updateAct vitvassociat ionso line ~295)updateActivityAssociations line~303hasEngagementActivityType line ~332updateTask line ~352createActiv1ty uine~393)createMeetingV5() (line ~484)saverolLowupAct1v1ty() (line ~598)SEARCHRate LimitBURSTBURSTBURSTIBURSTBURSTBURSTBURSTBURSTBURS1BURSTConsole,LOg XChanaes 12 files= env.local aor@ Client.oho apn/Services/Crm/Hubspot© HandleHubspotRateLimit.php app/Jobs/Middleware@ HubsnotClientinterface.phn.ann/Services/Crm/Hubspot© HubspotPaginationService.php app/Services/Crm/Hubspot/Pagination@.liminnvDehuaCommandinhn. ann/Concole/Commanddphp logging.php config© MatchActivityCrmData.php app/Jobs/Crm© MatchCrmData.php app/Jobs/Activity/Import©PaginationState.php app/Services/Crm/Hubspot/Pagination© RateLimitException.php app/Exceptionsc service.pnp agUnversioned Files 9 files=.env.nikilocal aprE.env.other appC) CanAccessAiReportsTest.php tests/Unit/Policies© CreateMockAskJiminnyReportResultCommand.php app/Console/Commands/R_k tavicon.ico public=ids.txt aodiaraw soll querv sall aool@ SimulateWebhooksCommand.ohn apn/Console/Commands/Crm/HubspotPatch successfully applied (moments ago)TJ0 + → Side-by-side viewer •8 35f036ac app/Services/Crm/Hubspot/Service.phpDo not ignoreHighlight wordsx 13 B ?private const string TYPE_NOTE = 'NOTE':private const string TYPE_MEETING = 'MEETING':liminnvl Services| Crm| Hubsnot > Service > find() VAUAAAAASdata = ml.// Use the new V3 API to find contacts based on additional fields.foreach (['companies', 'contacts'] as SobiectType) {/crm/vs/opnects, sobneccivoe . search'"Spayload = Sthis->qenerateNameSearchPavload(Sname. $offset. Scount):Stype = SobiectType === 'companies' ? 'account' : 'contact':try {$response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [= Spavload.// Build manned Listforeach (Sresnonsef'results'] as Sobiect) {Soronenties = Sobiect['oronerties'1)Ask anytning (dtL)+ ‹› CodeS AdaptiveCurront vorcionprivate const string TYPE_NOTE = 'NOTE';private const string TYPE_MEETING = 'MEETING':liminnv| Services| Crm| Hubsnot > Service > find() nnASdata = []:// Use the new V3 API to find contacts based on additional fields.foreach (['companies', 'contacts'] as SobjectType) {Spayload = Sthis->generateNameSearchPayload(Sname. Soffset. $count):Stype = SobiectType === 'companies' ? 'account' : 'contact':tryfSresponse = Sthis->client->search(SobiectType. Spayload):// Build mapped list.foreach (Sresponsef'results'] as Sobiect)fsorobertles = sobnect"pronerties'12 differencesW Windsurf Toams 100-1UTF.8io 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9803
|
443
|
4
|
2026-05-08T13:33:53.986916+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247233986_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
iTerm2Shell Edit ViewSessionScripts|ProfilesWind iTerm2Shell Edit ViewSessionScripts|ProfilesWindowHelp‹ $0 hil100% C8APP (-zsh)DOCKER- ₴81DEV (docker)882JY-20773-fix-automated-reports-user-pilot-trackingJY-20157-AJ-report-not-send-notificationJY-20508-notify-before-AJ-report-expirationJY-20372-ai-reports-promotion-pagesJY-20352-sync-opportunities-without-a-local-owner-user-id-is-nullJY-20738-debug-AJ-tracking-UPAPP (-zsh)-zshJY-18909-automated-reports-ask-jiminnyJY-20692-fix-integration-app-[API_KEY]@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limitSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'Lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ I• 84screenpipe*•$5-zshFri 8 May 16:33:57T₴1|₴6APP...
|
NULL
|
8740093310388782953
|
NULL
|
click
|
ocr
|
NULL
|
iTerm2Shell Edit ViewSessionScripts|ProfilesWind iTerm2Shell Edit ViewSessionScripts|ProfilesWindowHelp‹ $0 hil100% C8APP (-zsh)DOCKER- ₴81DEV (docker)882JY-20773-fix-automated-reports-user-pilot-trackingJY-20157-AJ-report-not-send-notificationJY-20508-notify-before-AJ-report-expirationJY-20372-ai-reports-promotion-pagesJY-20352-sync-opportunities-without-a-local-owner-user-id-is-nullJY-20738-debug-AJ-tracking-UPAPP (-zsh)-zshJY-18909-automated-reports-ask-jiminnyJY-20692-fix-integration-app-[API_KEY]@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limitSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'Lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ I• 84screenpipe*•$5-zshFri 8 May 16:33:57T₴1|₴6APP...
|
9801
|
NULL
|
NULL
|
NULL
|
|
9802
|
444
|
5
|
2026-05-08T13:33:51.720687+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247231720_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormVIewNavigateCodeLaravelKeractorFV faVsco.j PhostormVIewNavigateCodeLaravelKeractorFV faVsco.js?9 JY-20725-handle-HS-search-rate-limitProledeyJiminnyDebugcommand.ong= custom.log x=laravel.log4 SF jiminny@localhost4 HS_local (jiminny@localhost)console [PROD]A console leu)© BatchSyncCollector.f G DeleteCrmEntityTrait.phpo kematcnactiviyoncrmobjectbetach.org© CheckAndRetryRemoteMatch.php© baichsynckealsservc clientonp© ClosedDealStagesSe© PaginationState.phgC)MatchermData.phg@ DealFieldsService.ph# console [SlAvING[2026-05-07 14:21:15] local.INF0: [Hubspot] DEBUG Getting headers {M X19 A V"neaders".?"Vace".L"Inu,or May 2020 14.21.15 6Ml"Jc)Decorateacuiviv.one© FieldDefinitions.phcclass Service extends Baseservice 1mplementsm| A7A9 ×6^"Transter-Encoding": L"Chunked"J,use syncrielosiralcC) FieldTvpeconverter.r@ HubspotClientinterfause upporcunlcysynciralch"CF-Ray":["9f80deb8db60dc3a-SOF"].(C) HubspotTokenManac"CF-Cache-Status":L"DYNAMIC"J,"Strict-Transport-Secur1ty":"max-aqe=31536000* 1ncludeSubDomalns: preload")C) PavloadBuilder.ohpprivate const 1nt ENGAGEMENT_BODY_MAX_LENGTH = 655367(C) RemotecrmObiectMaC) ResponseNormalize.(c) Service.ond© SyncFieldAction.php(C) SvncRelatedActivitvi€ WebhookSvncBatch!v MintearationAoo8 usages1 usage1 usagelusageorivate const strind LUG DATE FORMAT &"Y-m-d HH1's':"server-timing": ["hcid;desc=|"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",cfr;desc=|"9f80deb8e7c6dc3a-1AD\""],"x-content-tvoe-ootions":"nosniff"private const int BATCH_UPDATE_LIMIT = 100;'x-hubsoot-correlation-id":"019e02d0-6fd8-7812-bdba-885b7cch3ee3"7private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';>IM Acceccord› D Api>D Config> DDTO> D FiltersD JobsD ProspectSearchStratM ServiceTraits© DataClient.php"Set-Cookie":["__cf_bm=S1UrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfW100.uf207-May-26 14:51:15 GMT; domain=.hubapi.com; Http0nly; Secure; SameSite=None"],"Renont-To"•f"sprivate const int TEN_SECONDLY_ROLLING_LIMIT = 10;"endnoints ".f1 usage"urz\":\"https:|\/\V/a.nel.cloudflare.com\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZ1zoYdxI%2BIxVpHmsKnprivate const string TYPE_NOTE = 'NOTE':Г24private const String TYPE_MEETING = 'MEETING':\"group\":\"cf-nel\".\"max_age\":604800}"],"NEL":["{\"success_fraction\":0.01,reporc to. "cr-nel,Local ChangesConsole,Log XTJ0 + → Side-by-side viewer •Do not ianoreHiahliaht words yXBB ?› Almost ready one call to be tested 8 files, Today 14:54v hubspot rate limit temp checkout 17 files, Today 11:48-. Client.php app/Services/Crm/Hubspot.ConferenceCrmMatcherJob.php app/Jobs/Activity-HandleHubspotRateLimit.php app/Jobs/MiddlewareHubspotClientinterface.php app/Services/Crm/Hubspot.HubspotPaginationService.php app/Services/Crm/Hubspot/PaginationE HydrateCrmDataByExternalCallidJob.php app/Jobs/Activity/Import# ImportBatchJobTrait.php app/Jobs/Crm/HubspotGl JiminnyDebugCommand.php app/Console/Commandsloadina.nho confidMatchActivityCrmData.php app/Jobs/CrmMatchCrmData.oho aoo.lobs/Activitv/imoort# OpportunitySyncTrait.php app/Services/Crm/Hubspot/ServiceTraits-PlavbackService.oho aoo/ServicesRateLimitException.php app/Exceptions# Service nhn annSyncHubspotObjects.php app/Jobs/[EMAIL] ann/.lohs/Crmlmbase version aoo/services/crm/rubsoot/Service.oho Shelved version162public function searchCallsForPeriod(Carbon $from, Carbon Sto): GeneratorCt 162716281400 G1* Sometimes we have secondlu rate limit error, then retru request after 1 second1740public function searchcallByRecordingURLToken(strina SplaybackurLtoken, bool Sretry = true: arrayCt 174217431724 GSendooint = httos://aoi.hubaoi.com/crm/v3/obiects/calls/searchiSpavload = Sthis->navloadBuilder->generateSearchCa11BvTokenPavload(SplavbackURLToken)=Sthis->logger->info('[HubSootl CRM Search by plavback URL token reauested'. [174617471728=> Spavload.17491756+nydcaved soarch delete 1 file 27 04 26 14-58> expiration message 6 files, 27.04.26, 9:23Sresponse = $this->client->getInstance()->getClient(->request('POST', $en} catch (Exception $exception) {Sthis->logger->info('[HubSpot] Search by playback URL token failed', [= (Snavload)l)100% 12Fri 8 May 16:33:52AskJiminnyReportActivityServiceTest vCascadeHubspot Rate LimitingHubSpot Rate Limit ReInvestigating Rate LinNew CascadeHubspot erm call Re+0 ..getinstanceo)ogeteuencoprequestt rost, /cauts/searcnysearchCallByRecordingURLToken() (line ~1752)SEARCHcreateNote()saveTranscriptionSummaryAsNote() (line ~1896)BURSTupdateEngagement()attachSummaryToActivity() (line ~1938)BURSTgetEngagementData()buildMetadataForSummaryUpdate() (line ~1960)BURSTgetPaginatedData($payload, 'deals')getDealsInBulk() (line ~2003)SEARCHServicetrarts/writecrmtrart.onoRate LimitimmaryToActivityo line ~120)BURSTupsertActivityo) (line ~172)BURSTupdateAct vitvassociat ionso line ~295)BURSTIupdateActivityAssociations line~303BURSTupdateMeetingV3 line ~326BURSThasEngagementActivityType line ~332BURSTupdateTask line ~352BURSTcreateActiv1ty uine~393)BURSTcreateMeetingV5() (line ~484)BURSTsaverolLowupAct1v1ty() (line ~598)BURSTServiceTraits/SvncCrmEnt1t1estralt.pnpMothad contovtLimit** 12 differencospublic function searchCallsForPeriod(Carbon Sfrom, Carbon $to): Generaton* Sometimes we have secondly rate limit error, then retru reguest after 1 secondpublic function searchcallByRecordinqurLToken(strina SplaybackURLtoken, bool Sretry = true: arraySpavload = Sthis->navloadBuilder->qenerateSearchCal1BvTokenPavload($plavbackURLToken)•Sthis->logger->info('[HubSpotl CRM Search by plavback URL token requested'. П'request' => Spavload1):+ny dSnosnonse = Sthis->client->seanch/"callcl Snavloadh•3catch Fycention Sexcention) &Sthis->logger->info('[HubSpot] Search by playback URL token failed', [InlavhacklIRI Token! => SnlavhackliRl Token.WN Windsurf Toams 100-1UTF.8io 4 spaces...
|
NULL
|
-6337075170531599094
|
NULL
|
visual_change
|
ocr
|
NULL
|
PhostormVIewNavigateCodeLaravelKeractorFV faVsco.j PhostormVIewNavigateCodeLaravelKeractorFV faVsco.js?9 JY-20725-handle-HS-search-rate-limitProledeyJiminnyDebugcommand.ong= custom.log x=laravel.log4 SF jiminny@localhost4 HS_local (jiminny@localhost)console [PROD]A console leu)© BatchSyncCollector.f G DeleteCrmEntityTrait.phpo kematcnactiviyoncrmobjectbetach.org© CheckAndRetryRemoteMatch.php© baichsynckealsservc clientonp© ClosedDealStagesSe© PaginationState.phgC)MatchermData.phg@ DealFieldsService.ph# console [SlAvING[2026-05-07 14:21:15] local.INF0: [Hubspot] DEBUG Getting headers {M X19 A V"neaders".?"Vace".L"Inu,or May 2020 14.21.15 6Ml"Jc)Decorateacuiviv.one© FieldDefinitions.phcclass Service extends Baseservice 1mplementsm| A7A9 ×6^"Transter-Encoding": L"Chunked"J,use syncrielosiralcC) FieldTvpeconverter.r@ HubspotClientinterfause upporcunlcysynciralch"CF-Ray":["9f80deb8db60dc3a-SOF"].(C) HubspotTokenManac"CF-Cache-Status":L"DYNAMIC"J,"Strict-Transport-Secur1ty":"max-aqe=31536000* 1ncludeSubDomalns: preload")C) PavloadBuilder.ohpprivate const 1nt ENGAGEMENT_BODY_MAX_LENGTH = 655367(C) RemotecrmObiectMaC) ResponseNormalize.(c) Service.ond© SyncFieldAction.php(C) SvncRelatedActivitvi€ WebhookSvncBatch!v MintearationAoo8 usages1 usage1 usagelusageorivate const strind LUG DATE FORMAT &"Y-m-d HH1's':"server-timing": ["hcid;desc=|"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",cfr;desc=|"9f80deb8e7c6dc3a-1AD\""],"x-content-tvoe-ootions":"nosniff"private const int BATCH_UPDATE_LIMIT = 100;'x-hubsoot-correlation-id":"019e02d0-6fd8-7812-bdba-885b7cch3ee3"7private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';>IM Acceccord› D Api>D Config> DDTO> D FiltersD JobsD ProspectSearchStratM ServiceTraits© DataClient.php"Set-Cookie":["__cf_bm=S1UrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfW100.uf207-May-26 14:51:15 GMT; domain=.hubapi.com; Http0nly; Secure; SameSite=None"],"Renont-To"•f"sprivate const int TEN_SECONDLY_ROLLING_LIMIT = 10;"endnoints ".f1 usage"urz\":\"https:|\/\V/a.nel.cloudflare.com\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZ1zoYdxI%2BIxVpHmsKnprivate const string TYPE_NOTE = 'NOTE':Г24private const String TYPE_MEETING = 'MEETING':\"group\":\"cf-nel\".\"max_age\":604800}"],"NEL":["{\"success_fraction\":0.01,reporc to. "cr-nel,Local ChangesConsole,Log XTJ0 + → Side-by-side viewer •Do not ianoreHiahliaht words yXBB ?› Almost ready one call to be tested 8 files, Today 14:54v hubspot rate limit temp checkout 17 files, Today 11:48-. Client.php app/Services/Crm/Hubspot.ConferenceCrmMatcherJob.php app/Jobs/Activity-HandleHubspotRateLimit.php app/Jobs/MiddlewareHubspotClientinterface.php app/Services/Crm/Hubspot.HubspotPaginationService.php app/Services/Crm/Hubspot/PaginationE HydrateCrmDataByExternalCallidJob.php app/Jobs/Activity/Import# ImportBatchJobTrait.php app/Jobs/Crm/HubspotGl JiminnyDebugCommand.php app/Console/Commandsloadina.nho confidMatchActivityCrmData.php app/Jobs/CrmMatchCrmData.oho aoo.lobs/Activitv/imoort# OpportunitySyncTrait.php app/Services/Crm/Hubspot/ServiceTraits-PlavbackService.oho aoo/ServicesRateLimitException.php app/Exceptions# Service nhn annSyncHubspotObjects.php app/Jobs/[EMAIL] ann/.lohs/Crmlmbase version aoo/services/crm/rubsoot/Service.oho Shelved version162public function searchCallsForPeriod(Carbon $from, Carbon Sto): GeneratorCt 162716281400 G1* Sometimes we have secondlu rate limit error, then retru request after 1 second1740public function searchcallByRecordingURLToken(strina SplaybackurLtoken, bool Sretry = true: arrayCt 174217431724 GSendooint = httos://aoi.hubaoi.com/crm/v3/obiects/calls/searchiSpavload = Sthis->navloadBuilder->generateSearchCa11BvTokenPavload(SplavbackURLToken)=Sthis->logger->info('[HubSootl CRM Search by plavback URL token reauested'. [174617471728=> Spavload.17491756+nydcaved soarch delete 1 file 27 04 26 14-58> expiration message 6 files, 27.04.26, 9:23Sresponse = $this->client->getInstance()->getClient(->request('POST', $en} catch (Exception $exception) {Sthis->logger->info('[HubSpot] Search by playback URL token failed', [= (Snavload)l)100% 12Fri 8 May 16:33:52AskJiminnyReportActivityServiceTest vCascadeHubspot Rate LimitingHubSpot Rate Limit ReInvestigating Rate LinNew CascadeHubspot erm call Re+0 ..getinstanceo)ogeteuencoprequestt rost, /cauts/searcnysearchCallByRecordingURLToken() (line ~1752)SEARCHcreateNote()saveTranscriptionSummaryAsNote() (line ~1896)BURSTupdateEngagement()attachSummaryToActivity() (line ~1938)BURSTgetEngagementData()buildMetadataForSummaryUpdate() (line ~1960)BURSTgetPaginatedData($payload, 'deals')getDealsInBulk() (line ~2003)SEARCHServicetrarts/writecrmtrart.onoRate LimitimmaryToActivityo line ~120)BURSTupsertActivityo) (line ~172)BURSTupdateAct vitvassociat ionso line ~295)BURSTIupdateActivityAssociations line~303BURSTupdateMeetingV3 line ~326BURSThasEngagementActivityType line ~332BURSTupdateTask line ~352BURSTcreateActiv1ty uine~393)BURSTcreateMeetingV5() (line ~484)BURSTsaverolLowupAct1v1ty() (line ~598)BURSTServiceTraits/SvncCrmEnt1t1estralt.pnpMothad contovtLimit** 12 differencospublic function searchCallsForPeriod(Carbon Sfrom, Carbon $to): Generaton* Sometimes we have secondly rate limit error, then retru reguest after 1 secondpublic function searchcallByRecordinqurLToken(strina SplaybackURLtoken, bool Sretry = true: arraySpavload = Sthis->navloadBuilder->qenerateSearchCal1BvTokenPavload($plavbackURLToken)•Sthis->logger->info('[HubSpotl CRM Search by plavback URL token requested'. П'request' => Spavload1):+ny dSnosnonse = Sthis->client->seanch/"callcl Snavloadh•3catch Fycention Sexcention) &Sthis->logger->info('[HubSpot] Search by playback URL token failed', [InlavhacklIRI Token! => SnlavhackliRl Token.WN Windsurf Toams 100-1UTF.8io 4 spaces...
|
9800
|
NULL
|
NULL
|
NULL
|
|
9801
|
443
|
3
|
2026-05-08T13:33:51.116398+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247231116_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
9
6
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
// $name,
// $this->getContactFields()
// );
// $hsContacts = $this->client->getPaginatedData($payload, 'contact');
// return null;
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata =...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"9","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"6","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\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n// $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n// $name,\n// $this->getContactFields()\n// );\n// $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n// return null;\n\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->search($objectType, $payload);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n try {\n return $this->client->search('contacts', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n// $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n// $name,\n// $this->getContactFields()\n// );\n// $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n// return null;\n\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n\n return $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n\n return null;\n }\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->search('calls', $payload);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"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}]...
|
1468664438918297136
|
-465546842829875097
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
9
6
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string TYPE_NOTE = 'NOTE';
private const string TYPE_MEETING = 'MEETING';
private const string TYPE_CALL = 'CALL';
private const string API_URL = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$payload = $this->generateNameSearchPayload($name, $offset, $count);
$type = $objectType === 'companies' ? 'account' : 'contact';
try {
$response = $this->client->search($objectType, $payload);
// Build mapped list.
foreach ($response['results'] as $object) {
$properties = $object['properties'];
$objectName = $this->buildContactName($properties);
$record = [
'crmId' => $object['id'],
// Pass crmUrl to the FE, needed for success message in the extension when you log activity.
'crmUrl' => $this->generateProviderUrl($object['id'], $type),
'name' => $objectName,
'prospectType' => $type,
'phoneNumbers' => [],
];
if ($type === 'account') {
$record['industry'] = $properties['industry'] ?? null;
} else {
$record['title'] = $properties['jobtitle'] ?? null;
$record['organization'] = $properties['company'] ?? null;
}
$countryCode = $this->buildContactCountry($properties);
$parsedNumber = $this->buildContactPhone($countryCode, $properties);
// Add phone number to record.
if (! empty($parsedNumber['phone'])) {
$record['phoneNumbers'][] = [
'number' => $parsedNumber['phone'],
'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),
'type' => 'phone',
];
}
// Add mobile phone number to record.
if (! empty($properties['mobilephone'])) {
$mobileNumber = phone_e164($countryCode, $properties['mobilephone']);
if ($mobileNumber !== null) {
$record['phoneNumbers'][] = [
'number' => $mobileNumber,
'nationalFormat' => phone_national($countryCode, $mobileNumber),
'type' => 'mobile',
];
}
}
$data[] = $record;
}
} catch (BadRequest $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->getUuid(),
'request' => $payload,
'reason' => $e->getMessage(),
]);
throw $e;
}
}
return $data;
},
);
}
/**
* @inheritdoc
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
try {
return $this->client->search('contacts', $payload);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'phone' => $phone,
'reason' => $exception->getMessage(),
]);
return [];
}
}
private function normalizePhoneNumber(string $phone): string
{
return ltrim(phone_e164(null, $phone), '+0');
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
// $name,
// $this->getContactFields()
// );
// $hsContacts = $this->client->getPaginatedData($payload, 'contact');
// return null;
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_ACCOUNT:
$this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_TASK:
// Endpoint for Engagements not ready
$engagements = [
'type' => 'TASK',
];
$metadata =...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9800
|
444
|
4
|
2026-05-08T13:33:44.582937+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247224582_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhpStormVIewINavigarecodeFV faVsco.jsProledey?9 JY PhpStormVIewINavigarecodeFV faVsco.jsProledey?9 JY-20725-handle-HS-search-rate-lJiminnyDebugcommand.ong© BatchSyncCollector.n DeleteCrmEntitylraicongo kematcnactiviyoncrmobjectbetach.org© CheckAndRetryRemoteMatch.php© baichsynckealsservc clientonp© ClosedDealStagesSe@ DealFieldsService.phc)DecorateAcuiviy.one© FieldDefinitions.phcC) FieldTvpeconverter.rU HubspotClientinterta 1172© HubspotTokenManaç 1173C) PavloadBuilder.ohp© RemoteCrmObjectMe 1175€ ResponseNormalize.$ 1176(c) Service.oho© SyncFieldAction.php 1178© SyncRelatedActivity 1179© WebhookSyncBatchF 1186v MintearationAoo>IM Acceccord>D Api>D Config> DDTOD Filters11861187class Service extends Baseservice 1mplements47 A48 X1 M33 21 Aprivate function handLePhonesearchrequest(string $phone, array spayload): arrayeeorn tatsthis->loqger->1nfo( HubSpot. Phone match completed'. "return Sresponse->toArravo:1 usageprivate function normalizePhoneNumber(string $phone): stringt...hAreturn nulllannaudD JobsProspectSearchStrat 1190M ServiceTraits11891191© DataClient.phpLead|null,Account/null,Opportunity|null,Contact/null,local ChangesConsole,› Almost ready one call to be tested 8 files, Today 14:54v hubspot rate limit temp checkout 17 files, Today 11:48-. Client.php app/Services/Crm/Hubspot.ConferenceCrmMatcherJob.php app/Jobs/Activity-HandleHubspotRateLimit.php app/Jobs/MiddlewareHubspotClientinterface.php app/Services/Crm/HubspotHubspotPaginationService.php app/Services/Crm/Hubspot/PaginationE HydrateCrmDataByExternalCallidJob.php app/Jobs/Activity/Import# ImportBatchJobTrait.php app/Jobs/Crm/HubspotGl JiminnyDebugCommand.php app/Console/Commandsloadina.nho confidMatchActivityCrmData.php app/Jobs/CrmMatchCrmData.oho avo.Jobs/Activitv/imoortl# OpportunitySyncTrait.php app/Services/Crm/Hubspot/ServiceTraits-PlavbackService.oho aoo/ServicesRateLimitException.php app/Exceptions# Service nhn ann/Services/Crm/HubsnotSyncHubspotObjects.php app/Jobs/[EMAIL] ann/.lohs/Crmlcaved soarch delete 1 file 27 04. 26 14-58> expiration message 6 files, 27.04.26, 9:23= custom.log x=laravel.logA SF [jiminny@localhost4 HS_local [jiminny@localhost]console [PROD]A console leu)A console [STAGING]accent-encodinag""access-control-allow-credentials":["false"]."server-timing": ["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",cfr;desc=|"9f80deb8e7c6dc3a-IAD\""],"x-content-type-options":("nosniff"]."x-hubspot-correlation-id":("019e02d0-6fd8-7812-bdba-885b7ccb3ee3"].laoaaasrleese"set-LookLe":_ct._om=s1urcaugxvrzksopacronzvyknzinubzovniabelcmoy-1//81050/0-[IP_ADDRESS]-r1.299cUkxIgeszro2gDBTWM00.Ut207-May-26 14:51:15 GMT: domain=.hubapi.com;Http0nly: Secure: SameSite=None"],"керогс-10":"1"endpornts"."Lnlmuos:valVa.nelcloudrLare. com neDontVvLrSENYALSy PorYmSyOSUnXYE-4SCKYKORSDSWHSMO-04 20Y0X 46743 XVDHMSK"max age":604800-"],"success_fraction\":0.01,"max ade":604800-""Serven":"cloudflare"?,"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab","tnace id"."c7ab8365-903f-46d4-9403-0e5b551e3545"}TJ0 + → Side-by-side viewer •Do not ianoreHighlight words x 13 ?mbase version aoo/services/crm/rubsoot/Service.ohopublic function searchCallsForPeriod(Carbon Sfrom, Carbon Sto): Generator* Sometimes we have secondlu rate limit error, then retru request after 1 secondpublic function searchcallByRecordingURLToken(strina SplaybackurLtoken, bool Sretry = true: arraySendooint = httos://aoi.hubaoi.com/crm/v3/obiects/calls/searchiSpavload = Sthis->navloadBuilder->aenerateSearchCal1BvTokenPavload(SplavbackURLToken)=Sthis->logger->info('[HubSootl CRM Search by plavback URL token reauested'. [=> Spavload.+nydSresponse = Sthis->client->getInstance)->getClient->request('POST', $endpoint, ['json' => (Spayload)]):} catch (Exception Sexception) {Sthis->logger->info('[HubSpot] Search by playback URL token failed', [ Shelved version1625Ct16271609 C16281740Ct 174217431724 G Hubspot Rate LimitingHubSpot Rate Limit ReInvestigating Rate LingetEngagementData()getPaginatedData(Spayload, "deals')servicelralts/writecrmirait.onpgetclient()→>removeAssoclat1onsgetclient()→>gettngagementData()getclient()->deletetngagementgetClient()->createEngagement()gerttrentt)-createneetinglgetClient()->createEngagement()Ask anytning (dtL)S Adaptive100% 12Fri 8 May 16:33:46Hubspot erm call Re+0 ..scarchcaLtoykecordingURLToken() (line ~1752)SEARCHsaveTiranscriot.ionSummarvAsNote (line ~1896)BURSTattachSummaryToActivity() (line ~1938)BURSTbuildMetadataForSummaryUpdate() (line ~1960)BURSTgetDealsInBulk line ~2003)SEARCHMethod contextRate LimitlaryToActivity) (line ~120)BURSTupsertActivity line ~172BURSTupdateActiv1tyassoc1at1ons) line ~29oBURSTupdateActivityAssociations() (line ~303)BURSTupdatereetingv5() (uine ~326BURSThastngagementActiv1tylype() (line~332,BURSTupdateTask() (line ~352)BURSTcreateActivity() (line ~393)BURSTcreateMeetingV3() (line ~484)RUIPSTsaveFollownActivitvl)) lline -508)12 differencespublic function searchCallsForPeriod(Carbon Sfrom, Carbon $to): Generaton* Sometimes we have secondlu rate limit error, then retru request after 1 secondpublic function searchcallByRecordinqurLToken(strina SplaybackURLtoken, bool Sretry = true: arraySpavload = Sthis->navloadBuilder->qenerateSearchCal1BvTokenPavload($plavbackURLToken)•Sthis->logger->info('[HubSpotl CRM Search by plavback URL token requested'. П'request' => Spavload+ny dSnosnonse = Sthis->client->seanch/"callcl Snavloadh•3catch Fycention Sexcention) &Sthis->logger->info('[HubSpot] Search by playback URL token failed', [InlavhackliRI Token' => SnlavhackliRl Token.=W Windsurf Toams 1155.5 /705 charc 25 line breakc)UTF.8)fo 4 spaces...
|
NULL
|
-2039729203907971775
|
NULL
|
click
|
ocr
|
NULL
|
PhpStormVIewINavigarecodeFV faVsco.jsProledey?9 JY PhpStormVIewINavigarecodeFV faVsco.jsProledey?9 JY-20725-handle-HS-search-rate-lJiminnyDebugcommand.ong© BatchSyncCollector.n DeleteCrmEntitylraicongo kematcnactiviyoncrmobjectbetach.org© CheckAndRetryRemoteMatch.php© baichsynckealsservc clientonp© ClosedDealStagesSe@ DealFieldsService.phc)DecorateAcuiviy.one© FieldDefinitions.phcC) FieldTvpeconverter.rU HubspotClientinterta 1172© HubspotTokenManaç 1173C) PavloadBuilder.ohp© RemoteCrmObjectMe 1175€ ResponseNormalize.$ 1176(c) Service.oho© SyncFieldAction.php 1178© SyncRelatedActivity 1179© WebhookSyncBatchF 1186v MintearationAoo>IM Acceccord>D Api>D Config> DDTOD Filters11861187class Service extends Baseservice 1mplements47 A48 X1 M33 21 Aprivate function handLePhonesearchrequest(string $phone, array spayload): arrayeeorn tatsthis->loqger->1nfo( HubSpot. Phone match completed'. "return Sresponse->toArravo:1 usageprivate function normalizePhoneNumber(string $phone): stringt...hAreturn nulllannaudD JobsProspectSearchStrat 1190M ServiceTraits11891191© DataClient.phpLead|null,Account/null,Opportunity|null,Contact/null,local ChangesConsole,› Almost ready one call to be tested 8 files, Today 14:54v hubspot rate limit temp checkout 17 files, Today 11:48-. Client.php app/Services/Crm/Hubspot.ConferenceCrmMatcherJob.php app/Jobs/Activity-HandleHubspotRateLimit.php app/Jobs/MiddlewareHubspotClientinterface.php app/Services/Crm/HubspotHubspotPaginationService.php app/Services/Crm/Hubspot/PaginationE HydrateCrmDataByExternalCallidJob.php app/Jobs/Activity/Import# ImportBatchJobTrait.php app/Jobs/Crm/HubspotGl JiminnyDebugCommand.php app/Console/Commandsloadina.nho confidMatchActivityCrmData.php app/Jobs/CrmMatchCrmData.oho avo.Jobs/Activitv/imoortl# OpportunitySyncTrait.php app/Services/Crm/Hubspot/ServiceTraits-PlavbackService.oho aoo/ServicesRateLimitException.php app/Exceptions# Service nhn ann/Services/Crm/HubsnotSyncHubspotObjects.php app/Jobs/[EMAIL] ann/.lohs/Crmlcaved soarch delete 1 file 27 04. 26 14-58> expiration message 6 files, 27.04.26, 9:23= custom.log x=laravel.logA SF [jiminny@localhost4 HS_local [jiminny@localhost]console [PROD]A console leu)A console [STAGING]accent-encodinag""access-control-allow-credentials":["false"]."server-timing": ["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",cfr;desc=|"9f80deb8e7c6dc3a-IAD\""],"x-content-type-options":("nosniff"]."x-hubspot-correlation-id":("019e02d0-6fd8-7812-bdba-885b7ccb3ee3"].laoaaasrleese"set-LookLe":_ct._om=s1urcaugxvrzksopacronzvyknzinubzovniabelcmoy-1//81050/0-[IP_ADDRESS]-r1.299cUkxIgeszro2gDBTWM00.Ut207-May-26 14:51:15 GMT: domain=.hubapi.com;Http0nly: Secure: SameSite=None"],"керогс-10":"1"endpornts"."Lnlmuos:valVa.nelcloudrLare. com neDontVvLrSENYALSy PorYmSyOSUnXYE-4SCKYKORSDSWHSMO-04 20Y0X 46743 XVDHMSK"max age":604800-"],"success_fraction\":0.01,"max ade":604800-""Serven":"cloudflare"?,"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab","tnace id"."c7ab8365-903f-46d4-9403-0e5b551e3545"}TJ0 + → Side-by-side viewer •Do not ianoreHighlight words x 13 ?mbase version aoo/services/crm/rubsoot/Service.ohopublic function searchCallsForPeriod(Carbon Sfrom, Carbon Sto): Generator* Sometimes we have secondlu rate limit error, then retru request after 1 secondpublic function searchcallByRecordingURLToken(strina SplaybackurLtoken, bool Sretry = true: arraySendooint = httos://aoi.hubaoi.com/crm/v3/obiects/calls/searchiSpavload = Sthis->navloadBuilder->aenerateSearchCal1BvTokenPavload(SplavbackURLToken)=Sthis->logger->info('[HubSootl CRM Search by plavback URL token reauested'. [=> Spavload.+nydSresponse = Sthis->client->getInstance)->getClient->request('POST', $endpoint, ['json' => (Spayload)]):} catch (Exception Sexception) {Sthis->logger->info('[HubSpot] Search by playback URL token failed', [ Shelved version1625Ct16271609 C16281740Ct 174217431724 G Hubspot Rate LimitingHubSpot Rate Limit ReInvestigating Rate LingetEngagementData()getPaginatedData(Spayload, "deals')servicelralts/writecrmirait.onpgetclient()→>removeAssoclat1onsgetclient()→>gettngagementData()getclient()->deletetngagementgetClient()->createEngagement()gerttrentt)-createneetinglgetClient()->createEngagement()Ask anytning (dtL)S Adaptive100% 12Fri 8 May 16:33:46Hubspot erm call Re+0 ..scarchcaLtoykecordingURLToken() (line ~1752)SEARCHsaveTiranscriot.ionSummarvAsNote (line ~1896)BURSTattachSummaryToActivity() (line ~1938)BURSTbuildMetadataForSummaryUpdate() (line ~1960)BURSTgetDealsInBulk line ~2003)SEARCHMethod contextRate LimitlaryToActivity) (line ~120)BURSTupsertActivity line ~172BURSTupdateActiv1tyassoc1at1ons) line ~29oBURSTupdateActivityAssociations() (line ~303)BURSTupdatereetingv5() (uine ~326BURSThastngagementActiv1tylype() (line~332,BURSTupdateTask() (line ~352)BURSTcreateActivity() (line ~393)BURSTcreateMeetingV3() (line ~484)RUIPSTsaveFollownActivitvl)) lline -508)12 differencespublic function searchCallsForPeriod(Carbon Sfrom, Carbon $to): Generaton* Sometimes we have secondlu rate limit error, then retru request after 1 secondpublic function searchcallByRecordinqurLToken(strina SplaybackURLtoken, bool Sretry = true: arraySpavload = Sthis->navloadBuilder->qenerateSearchCal1BvTokenPavload($plavbackURLToken)•Sthis->logger->info('[HubSpotl CRM Search by plavback URL token requested'. П'request' => Spavload+ny dSnosnonse = Sthis->client->seanch/"callcl Snavloadh•3catch Fycention Sexcention) &Sthis->logger->info('[HubSpot] Search by playback URL token failed', [InlavhackliRI Token' => SnlavhackliRl Token.=W Windsurf Toams 1155.5 /705 charc 25 line breakc)UTF.8)fo 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9798
|
444
|
3
|
2026-05-08T13:33:42.855718+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247222855_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2045079508751645345
|
-465124630364809113
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
9796
|
NULL
|
NULL
|
NULL
|
|
9797
|
443
|
1
|
2026-05-08T13:33:36.367171+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247216367_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"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}]...
|
2045079508751645345
|
-465124630364809113
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
9790
|
NULL
|
NULL
|
NULL
|
|
9796
|
444
|
2
|
2026-05-08T13:33:35.946425+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247215946_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"}]...
|
-619518493505955855
|
-3048904809050027062
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9795
|
444
|
1
|
2026-05-08T13:32:53.531987+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247173531_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2045079508751645345
|
-465124630364809113
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
9794
|
NULL
|
NULL
|
NULL
|
|
9794
|
444
|
0
|
2026-05-08T13:32:48.455921+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247168455_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6337657672782761712
|
-3048904810292590134
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9793
|
443
|
0
|
2026-05-08T13:32:43.682485+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247163682_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":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}]...
|
2045079508751645345
|
-465124630364809113
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
9790
|
NULL
|
NULL
|
NULL
|
|
9792
|
NULL
|
0
|
2026-05-08T13:32:26.290155+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247146290_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2045079508751645345
|
-465124630364809113
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
9791
|
NULL
|
NULL
|
NULL
|
|
9791
|
442
|
14
|
2026-05-08T13:32:10.837359+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247130837_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6337657672782761712
|
-3048904810292590134
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9790
|
NULL
|
0
|
2026-05-08T13:32:09.938260+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247129938_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"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}]...
|
2045079508751645345
|
-465124630364809113
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9789
|
442
|
13
|
2026-05-08T13:32:04.393564+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247124393_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"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}]...
|
-4679073701681381133
|
-3048904792944948790
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:...
|
9786
|
NULL
|
NULL
|
NULL
|
|
9788
|
441
|
11
|
2026-05-08T13:32:03.628740+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247123628_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
6337657672782761712
|
-3048904810292590134
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}...
|
9785
|
NULL
|
NULL
|
NULL
|
|
9787
|
441
|
10
|
2026-05-08T13:31:39.190831+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247099190_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"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}]...
|
2045079508751645345
|
-465124630364809113
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
9785
|
NULL
|
NULL
|
NULL
|
|
9786
|
442
|
12
|
2026-05-08T13:31:35.371962+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247095371_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2045079508751645345
|
-465124630364809113
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9785
|
441
|
9
|
2026-05-08T13:31:08.333662+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247068333_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"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}]...
|
2045079508751645345
|
-465124630364809113
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9784
|
442
|
11
|
2026-05-08T13:31:03.258918+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247063258_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next 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":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-1990129186036834657
|
-3048905016283247670
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error...
|
9783
|
NULL
|
NULL
|
NULL
|
|
9783
|
442
|
10
|
2026-05-08T13:30:51.102701+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247051102_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2045079508751645345
|
-465124630364809113
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9780
|
441
|
7
|
2026-05-08T13:30:16.143006+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247016143_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"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}]...
|
2045079508751645345
|
-465124630364809113
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
9772
|
NULL
|
NULL
|
NULL
|
|
9779
|
442
|
8
|
2026-05-08T13:30:10.785051+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778247010785_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2045079508751645345
|
-465124630364809113
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
9777
|
NULL
|
NULL
|
NULL
|
|
9778
|
441
|
6
|
2026-05-08T13:29:44.828610+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778246984828_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"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}]...
|
2045079508751645345
|
-465124630364809113
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
9772
|
NULL
|
NULL
|
NULL
|
|
9777
|
442
|
7
|
2026-05-08T13:29:40.098199+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778246980098_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2045079508751645345
|
-465124630364809113
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9776
|
442
|
6
|
2026-05-08T13:29:37.124949+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778246977124_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"}]...
|
5386996761710030787
|
-3048905908561654838
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33...
|
9773
|
NULL
|
NULL
|
NULL
|
|
9775
|
442
|
5
|
2026-05-08T13:29:28.651872+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778246968651_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2045079508751645345
|
-465124630364809113
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
9773
|
NULL
|
NULL
|
NULL
|
|
9774
|
441
|
5
|
2026-05-08T13:29:06.755486+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778246946755_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"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}]...
|
2045079508751645345
|
-465124630364809113
|
idle
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
9772
|
NULL
|
NULL
|
NULL
|
|
9773
|
442
|
4
|
2026-05-08T13:28:54.638225+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778246934638_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2045079508751645345
|
-465124630364809113
|
visual_change
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9772
|
441
|
4
|
2026-05-08T13:28:33.643305+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778246913643_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"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}]...
|
2045079508751645345
|
-465124630364809113
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9771
|
441
|
3
|
2026-05-08T13:28:21.429793+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778246901429_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
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":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
-5805527035028425421
|
-8132083611339641914
|
click
|
hybrid
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
iTerm2Shell Edit ViewSessionScripts|ProfilesWindowHelp‹ $0 hil100% C8APP (-zsh)DOCKERDEV (docker)882JY-20773-fix-automated-reports-user-pilot-trackingJY-20157-AJ-report-not-send-notificationJY-20508-notify-before-AJ-report-expirationJY-20372-ai-reports-promotion-pagesJY-20352-sync-opportunities-without-a-local-owner-user-id-is-nullJY-20738-debug-AJ-tracking-UPAPP (-zsh)-zshJY-18909-automated-reports-ask-jiminnyJY-20692-fix-integration-app-[API_KEY]@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (master) $ co -b JY-20725-handle-HS-search-rate-limitSwitched to a new branch 'JY-20725-handle-HS-search-rate-limit'Lukas@Lukas-Kovaliks-MacBook-Pro-Jiminny ~/jiminny/app (JY-20725-handle-HS-search-rate-limit) $ I• 84screenpipe*•$5-zshFri 8 May 16:28:24T₴1|₴6APP...
|
9768
|
NULL
|
NULL
|
NULL
|
|
9770
|
442
|
3
|
2026-05-08T13:28:21.429792+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778246901429_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"bounds":{"left":0.025930852,"top":0.019952115,"width":0.03856383,"height":0.025538707},"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"bounds":{"left":0.064494684,"top":0.019952115,"width":0.09541223,"height":0.025538707},"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"bounds":{"left":0.8081782,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"bounds":{"left":0.8234708,"top":0.019952115,"width":0.09208777,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9155585,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"bounds":{"left":0.9268617,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"bounds":{"left":0.9381649,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"bounds":{"left":0.96609044,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"bounds":{"left":0.9773936,"top":0.019952115,"width":0.011303191,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"bounds":{"left":0.9886968,"top":0.019952115,"width":0.011303186,"height":0.025538707},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"bounds":{"left":0.6615692,"top":0.10055866,"width":0.009640957,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.67287236,"top":0.09896249,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.68018615,"top":0.09896249,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"bounds":{"left":0.37632978,"top":0.0,"width":0.5728058,"height":0.632083},"on_screen":true,"lines":[{"char_start":624,"char_count":72,"bounds":{"left":0.37632978,"top":0.0,"width":0.18384309,"height":0.014365523}},{"char_start":696,"char_count":219,"bounds":{"left":0.37632978,"top":0.0,"width":0.56515956,"height":0.014365523}},{"char_start":915,"char_count":83,"bounds":{"left":0.37632978,"top":0.0,"width":0.21243352,"height":0.014365523}},{"char_start":998,"char_count":20,"bounds":{"left":0.37632978,"top":0.0,"width":0.04920213,"height":0.014365523}},{"char_start":1018,"char_count":17,"bounds":{"left":0.37632978,"top":0.0,"width":0.041223403,"height":0.014365523}},{"char_start":1035,"char_count":203,"bounds":{"left":0.37632978,"top":0.0,"width":0.52360374,"height":0.014365523}},{"char_start":1238,"char_count":22,"bounds":{"left":0.37632978,"top":0.007980846,"width":0.05418883,"height":0.014365523}},{"char_start":1260,"char_count":23,"bounds":{"left":0.37632978,"top":0.025538707,"width":0.056848403,"height":0.014365523}},{"char_start":1283,"char_count":10,"bounds":{"left":0.37632978,"top":0.04309657,"width":0.023271276,"height":0.014365523}},{"char_start":1293,"char_count":27,"bounds":{"left":0.37632978,"top":0.060654428,"width":0.06715426,"height":0.014365523}},{"char_start":1320,"char_count":26,"bounds":{"left":0.37632978,"top":0.07821229,"width":0.06482713,"height":0.014365523}},{"char_start":1346,"char_count":23,"bounds":{"left":0.37632978,"top":0.09577015,"width":0.056848403,"height":0.014365523}},{"char_start":1369,"char_count":28,"bounds":{"left":0.37632978,"top":0.11332801,"width":0.06981383,"height":0.014365523}},{"char_start":1397,"char_count":57,"bounds":{"left":0.37632978,"top":0.13088587,"width":0.14494681,"height":0.014365523}}],"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.042220745,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"bounds":{"left":0.29022607,"top":0.17478053,"width":0.0076462766,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"bounds":{"left":0.29986703,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.31216756,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"bounds":{"left":0.32147607,"top":0.17478053,"width":0.010305851,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"bounds":{"left":0.3337766,"top":0.17478053,"width":0.00731383,"height":0.015163607},"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"bounds":{"left":0.34275267,"top":0.17318435,"width":0.00731383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"bounds":{"left":0.35006648,"top":0.17318435,"width":0.006981383,"height":0.018355945},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"Project","depth":3,"on_screen":false,"role_description":"text"},{"role":"AXButton","text":"Project","depth":3,"bounds":{"left":0.011968086,"top":0.047885075,"width":0.024268618,"height":0.024740623},"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"New File or Directory…","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Expand Selected","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Collapse All","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Options","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.27027926,"top":1.0,"width":0.008643617,"height":0.0},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false}]...
|
2045079508751645345
|
-465124630364809113
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
9769
|
NULL
|
NULL
|
NULL
|
|
9769
|
442
|
2
|
2026-05-08T13:28:01.006192+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778246881006_m2.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_2
|
NULL
|
NULL
|
NULL
|
NULL
|
PhostormVIewNavigatecodeFV faVsco.js°9 JY-20725-ha PhostormVIewNavigatecodeFV faVsco.js°9 JY-20725-handle-HS-search-rate-limitProledeyJiminnyDebugcommand.ong(C) RemoteCrmObiectM:ResponseNormalize./T DeleteCrmEntity lraicongo kematcnactiviyoncrmobjectbetach.org© CheckAndRetryRemoteMatch.phpservice.onp© syncrielaAction.ong© SyncRelatedActivitylC) ProviderkateLimiter.phpHubspotPaqinationservice.onoc wednooksynebatcnhv D IntegrationAppclass Service extends Baseservice 1mplementsA7 A48 X1 X33 21 A V 11>D Accessors> C ApicontiaDDTO>D Filters1 usageprivate function buildMetadataForSummaryUpdate(Activity Sactivity, string $summary): a1989 гpublic function fetchAndAssociateRelatedActivity(Activity $activity): ?Activityt...›1994 G1public function fetchRelatedActivity(Activity Sactivity): array{...}• ProsoectSearchStrat 4770M Service Traits© DataClient.php© DecorateActivitv.ohnpublic function getDealsInBulk(array $dealIds): array© LocalSearch.phpelocalSearchinterfaceSpayload = $this->payloadBuilder->getDealsInBulkPayload($dealIds):© RemoteSearch.php© Service.phpv D Listeners© ConvertLeadActivitie 2006© PurgeLookupCache.r 200%> D Metadata> D MigrationD Pipedrivereturn Sthis->client->qetPaqinatedData(Spayload, type: 'deals'):* Extract deal IUs from Hubspot search response.* oparam arrou shubspotresponse Ine row hubsoor search Ar response: Onaram bool SincludeArchived Whether to include archived deals (default: false).Local ChangesConsole,TJ 0 + → Side-by-side viewer •Do not ianoreHighlight words x 13 ?› Almost ready one call to be tested 8 files, Today 14:54v hubspot rate limit temp checkout 17 files, Today 11:48-. Client.php app/Services/Crm/Hubspot.ConferenceCrmMatcherJob.php app/Jobs/Activity-HandleHubspotRateLimit.php app/Jobs/MiddlewareHubspotClientinterface.php app/Services/Crm/HubspotHubspotPaginationService.php app/Services/Crm/Hubspot/PaginationE HydrateCrmDataByExternalCallidJob.php app/Jobs/Activity/Import# ImportBatchJobTrait.php app/Jobs/Crm/HubspotGl JiminnyDebugCommand.php app/Console/Commandsloadina.nho confidMatchActivityCrmData.php app/Jobs/CrmMatchCrmData.oho avo.Jobs/Activitv/imoori# OpportunitySyncTrait.php app/Services/Crm/Hubspot/ServiceTraits-PlavbackService.oho aoo/ServicesRateLimitException.php app/Exceptions# Service nhn ann/Services/crm/ubsnotSyncHubspotObjects.php app/Jobs/[EMAIL] ann/.lohs/Crmlm base version aoo/servuse HubSpot\Client\ Crm\Pipelines Model\PipelineStage:use HubSpot\Client\Crm \Properties Model\Property:use HubSpot \Discovery Discovery;use Jiminny Excentions Crmexcention:_lise liminnvl Eycentione|SocialAccount TokenToval idEycention:- use Jiminny liohs Crm. Notedhiect:_use Jiminny Models\Crm \Field;use Jiminnv Services Crm RaseClient.\Jiminny|Services\public function __construct(SocialAccountService $socialAccountService,HubspotPaginationService $paginationService,HubspotTokenManager $tokenManagercaved soarch delete 1 file 27 04 26 14-58parent:: construct($socialAccountService):Sthis->paginationService = SpaginationService:Sthis->tokenManager = $tokenManager:> expiration message 6 files, 27.04.26, 9:23od 122 minutes ado= custom.log X= laravel.logA SF [jiminny@localhost]4 HS_local [jiminny@localhost]# console [PKobJA console leu)A console [STAGING]accept-encoding"],M X19 A V"access-control-allow-credentials":["false"]."server-timing": ["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",cfr;desc=|"9f80deb8e7c6dc3a-IAD\""]"x-content-type-options":("nosniff"]."x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],"set-LookLe":_ct._om=s1urcaugxvrzksopacronzvyknzinubzovniabelcmoy-1//81050/0-[IP_ADDRESS]-r1.299cUkxIgeszro2gDBTWM00.Ut207-May-26 14:51:15 GMT: domain=.hubapi.com; Http0nly: Secure: SameSite=None"],"керогс-10":"1"endpornts"."Lnlmuos:valVa.nelcloudrLare. com neDontVvLrSENYALSy PorYmSyOSUnXYE-4SCKYKORSDSWHSMO-04 20Y0X 46743 XVDHMSK"max age":604800-"],"success_fraction\":0.01,"max ade":604800-""Serven":"cloudflare"?,"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab","tnace id"."c7ab8365-903f-46d4-9403-0e5b551e3545"}©47100% 12Fri 8 May 16:28:02AskJiminnyReportActivityServiceTest vCascadeHubspot Rate LimitingHubSpot Rate Limit ReInvestigating Rate LinHubspot erm call Re+0 ..ents . updatel,updateField() (line ~1418-1437)BURSTgetinstancet)->deals/contacts/companzes/engagements.getbyla)getCrm0bjectById() (line ~1479-1488)BURSTgetinstancel)→>deals()→updatel)updateOpportunityStage() (line ~1511)BURSTgetInstance()->getClient()->request('POST', /calls/search)getCalls() (line ~1577)SEARCHgetInstance()->getClient()->request('POST', /calls/search)searchCallsByPeriod() (line~1603)SEARCHgetInstance()->getClient()->request('GET', /calls/{id})getCall() (line ~1671)BURSTgetNewInstanced=scrmossobiects-batchAniO=undate("calls')bulkAddPlaybackURLToDescriptionRequest() (lineBURST~1711)getInstance()->getClient()->request('POST', /calls/search)searchCal1BvRecordinaURLToken() (line ~1752)SEARCHsaveTranscriptionSummaryAsNote() (line ~1896)BURSTattachSummaryToActivity (line ~1938BURSTaryUpdate® (line ~1960BURSTgetPaginatedData(Spayload, "deals')SEARCHServicelralts/Writecrmiralt.ondMethod contextgetClient->getEngagementDataattachSummaryToActivity line ~120BURSTupsertActivity line ~172BURSTupdateActiv1tyassoc1ations line~29bBURS1Ask anytning (dtL)+ ‹› CodeS Adaptive14 differences Shelved versionuse HubSpot\(lient\ Crm\Pipelines Model\PipelineStage:use HubSpot \Client \Crm \Properties Model\Property:use HubSpot Discovery Discovery:use Jiminny Excentions Crmexcention:use Jiminny Exceptions \RateLimitExceptionuse Jiminny Excentions SociallAccountTokeninval.idsxcentionguse Jiminny|Jobs\Crm\Note0bject;use Jiminnv Models Crm Field.use Jiminny\Services\Crm \BaseClient;480public function __construct(SocialAccountService $socialAccountService,HubenotPaainationSonvico CnaainationSonvicolHubenotTokenManadon CtokenManadonparent:: construct($socialAccountService):Sthic-SnaginationSenvice = SpaginationService:Sthis->tokenManager = $tokenManager:WN Windsurf Toams 2002-20 UTF.8io 4 spaces...
|
NULL
|
-479549170711454662
|
NULL
|
visual_change
|
ocr
|
NULL
|
PhostormVIewNavigatecodeFV faVsco.js°9 JY-20725-ha PhostormVIewNavigatecodeFV faVsco.js°9 JY-20725-handle-HS-search-rate-limitProledeyJiminnyDebugcommand.ong(C) RemoteCrmObiectM:ResponseNormalize./T DeleteCrmEntity lraicongo kematcnactiviyoncrmobjectbetach.org© CheckAndRetryRemoteMatch.phpservice.onp© syncrielaAction.ong© SyncRelatedActivitylC) ProviderkateLimiter.phpHubspotPaqinationservice.onoc wednooksynebatcnhv D IntegrationAppclass Service extends Baseservice 1mplementsA7 A48 X1 X33 21 A V 11>D Accessors> C ApicontiaDDTO>D Filters1 usageprivate function buildMetadataForSummaryUpdate(Activity Sactivity, string $summary): a1989 гpublic function fetchAndAssociateRelatedActivity(Activity $activity): ?Activityt...›1994 G1public function fetchRelatedActivity(Activity Sactivity): array{...}• ProsoectSearchStrat 4770M Service Traits© DataClient.php© DecorateActivitv.ohnpublic function getDealsInBulk(array $dealIds): array© LocalSearch.phpelocalSearchinterfaceSpayload = $this->payloadBuilder->getDealsInBulkPayload($dealIds):© RemoteSearch.php© Service.phpv D Listeners© ConvertLeadActivitie 2006© PurgeLookupCache.r 200%> D Metadata> D MigrationD Pipedrivereturn Sthis->client->qetPaqinatedData(Spayload, type: 'deals'):* Extract deal IUs from Hubspot search response.* oparam arrou shubspotresponse Ine row hubsoor search Ar response: Onaram bool SincludeArchived Whether to include archived deals (default: false).Local ChangesConsole,TJ 0 + → Side-by-side viewer •Do not ianoreHighlight words x 13 ?› Almost ready one call to be tested 8 files, Today 14:54v hubspot rate limit temp checkout 17 files, Today 11:48-. Client.php app/Services/Crm/Hubspot.ConferenceCrmMatcherJob.php app/Jobs/Activity-HandleHubspotRateLimit.php app/Jobs/MiddlewareHubspotClientinterface.php app/Services/Crm/HubspotHubspotPaginationService.php app/Services/Crm/Hubspot/PaginationE HydrateCrmDataByExternalCallidJob.php app/Jobs/Activity/Import# ImportBatchJobTrait.php app/Jobs/Crm/HubspotGl JiminnyDebugCommand.php app/Console/Commandsloadina.nho confidMatchActivityCrmData.php app/Jobs/CrmMatchCrmData.oho avo.Jobs/Activitv/imoori# OpportunitySyncTrait.php app/Services/Crm/Hubspot/ServiceTraits-PlavbackService.oho aoo/ServicesRateLimitException.php app/Exceptions# Service nhn ann/Services/crm/ubsnotSyncHubspotObjects.php app/Jobs/[EMAIL] ann/.lohs/Crmlm base version aoo/servuse HubSpot\Client\ Crm\Pipelines Model\PipelineStage:use HubSpot\Client\Crm \Properties Model\Property:use HubSpot \Discovery Discovery;use Jiminny Excentions Crmexcention:_lise liminnvl Eycentione|SocialAccount TokenToval idEycention:- use Jiminny liohs Crm. Notedhiect:_use Jiminny Models\Crm \Field;use Jiminnv Services Crm RaseClient.\Jiminny|Services\public function __construct(SocialAccountService $socialAccountService,HubspotPaginationService $paginationService,HubspotTokenManager $tokenManagercaved soarch delete 1 file 27 04 26 14-58parent:: construct($socialAccountService):Sthis->paginationService = SpaginationService:Sthis->tokenManager = $tokenManager:> expiration message 6 files, 27.04.26, 9:23od 122 minutes ado= custom.log X= laravel.logA SF [jiminny@localhost]4 HS_local [jiminny@localhost]# console [PKobJA console leu)A console [STAGING]accept-encoding"],M X19 A V"access-control-allow-credentials":["false"]."server-timing": ["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",cfr;desc=|"9f80deb8e7c6dc3a-IAD\""]"x-content-type-options":("nosniff"]."x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],"set-LookLe":_ct._om=s1urcaugxvrzksopacronzvyknzinubzovniabelcmoy-1//81050/0-[IP_ADDRESS]-r1.299cUkxIgeszro2gDBTWM00.Ut207-May-26 14:51:15 GMT: domain=.hubapi.com; Http0nly: Secure: SameSite=None"],"керогс-10":"1"endpornts"."Lnlmuos:valVa.nelcloudrLare. com neDontVvLrSENYALSy PorYmSyOSUnXYE-4SCKYKORSDSWHSMO-04 20Y0X 46743 XVDHMSK"max age":604800-"],"success_fraction\":0.01,"max ade":604800-""Serven":"cloudflare"?,"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab","tnace id"."c7ab8365-903f-46d4-9403-0e5b551e3545"}©47100% 12Fri 8 May 16:28:02AskJiminnyReportActivityServiceTest vCascadeHubspot Rate LimitingHubSpot Rate Limit ReInvestigating Rate LinHubspot erm call Re+0 ..ents . updatel,updateField() (line ~1418-1437)BURSTgetinstancet)->deals/contacts/companzes/engagements.getbyla)getCrm0bjectById() (line ~1479-1488)BURSTgetinstancel)→>deals()→updatel)updateOpportunityStage() (line ~1511)BURSTgetInstance()->getClient()->request('POST', /calls/search)getCalls() (line ~1577)SEARCHgetInstance()->getClient()->request('POST', /calls/search)searchCallsByPeriod() (line~1603)SEARCHgetInstance()->getClient()->request('GET', /calls/{id})getCall() (line ~1671)BURSTgetNewInstanced=scrmossobiects-batchAniO=undate("calls')bulkAddPlaybackURLToDescriptionRequest() (lineBURST~1711)getInstance()->getClient()->request('POST', /calls/search)searchCal1BvRecordinaURLToken() (line ~1752)SEARCHsaveTranscriptionSummaryAsNote() (line ~1896)BURSTattachSummaryToActivity (line ~1938BURSTaryUpdate® (line ~1960BURSTgetPaginatedData(Spayload, "deals')SEARCHServicelralts/Writecrmiralt.ondMethod contextgetClient->getEngagementDataattachSummaryToActivity line ~120BURSTupsertActivity line ~172BURSTupdateActiv1tyassoc1ations line~29bBURS1Ask anytning (dtL)+ ‹› CodeS Adaptive14 differences Shelved versionuse HubSpot\(lient\ Crm\Pipelines Model\PipelineStage:use HubSpot \Client \Crm \Properties Model\Property:use HubSpot Discovery Discovery:use Jiminny Excentions Crmexcention:use Jiminny Exceptions \RateLimitExceptionuse Jiminny Excentions SociallAccountTokeninval.idsxcentionguse Jiminny|Jobs\Crm\Note0bject;use Jiminnv Models Crm Field.use Jiminny\Services\Crm \BaseClient;480public function __construct(SocialAccountService $socialAccountService,HubenotPaainationSonvico CnaainationSonvicolHubenotTokenManadon CtokenManadonparent:: construct($socialAccountService):Sthic-SnaginationSenvice = SpaginationService:Sthis->tokenManager = $tokenManager:WN Windsurf Toams 2002-20 UTF.8io 4 spaces...
|
NULL
|
NULL
|
NULL
|
NULL
|
|
9768
|
441
|
2
|
2026-05-08T13:28:00.971309+00:00
|
/Users/lukas/.screenpipe/data/data/2026-05-08/1778 /Users/lukas/.screenpipe/data/data/2026-05-08/1778246880971_m1.jpg...
|
PhpStorm
|
faVsco.js – Service.php
|
1
|
NULL
|
monitor_1
|
NULL
|
NULL
|
NULL
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
[{"role":"AXButton","text" [{"role":"AXButton","text":"Project: faVsco.js, menu","depth":5,"on_screen":true,"help_text":"~/jiminny/app","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JY-20725-handle-HS-search-rate-limit, menu","depth":5,"on_screen":true,"help_text":"Git Branch: JY-20725-handle-HS-search-rate-limit","role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Start Listening for PHP Debug Connections","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"AskJiminnyReportActivityServiceTest","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Run 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Debug 'AskJiminnyReportActivityServiceTest'","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"More Actions","depth":6,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"JetBrains AI","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Search Everywhere","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"IDE and Project Settings","depth":5,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"19","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","depth":4,"on_screen":true,"value":"[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {\n\"headers\":{\n\"Date\":[\"Thu,07 May 2026 14:21:15 GMT\"],\n \"Content-Type\":[\"application/json;charset=utf-8\"],\n \"Transfer-Encoding\":[\"chunked\"],\n \"Connection\":[\"keep-alive\"],\n \"CF-Ray\":[\"9f80deb8db60dc3a-SOF\"],\n \"CF-Cache-Status\":[\"DYNAMIC\"],\n \"Strict-Transport-Security\":[\"max-age=31536000; includeSubDomains; preload\"],\n \"Vary\":[\"origin,\n accept-encoding\"],\n \"access-control-allow-credentials\":[\"false\"],\n \"server-timing\":[\"hcid;desc=\\\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\\\",\n cfr;desc=\\\"9f80deb8e7c6dc3a-IAD\\\"\"],\n \"x-content-type-options\":[\"nosniff\"],\n \"x-hubspot-correlation-id\":[\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\"],\n \"Set-Cookie\":[\"__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-1.0.1.1-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,\n 07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None\"],\n \"Report-To\":[\"{\n\\\"endpoints\\\":[{\n\\\"url\\\":\\\"https:\\\\/\\\\/a.nel.cloudflare.com\\\\/report\\\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\\\"}],\n\\\"group\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"NEL\":[\"{\n\\\"success_fraction\\\":0.01,\n\\\"report_to\\\":\\\"cf-nel\\\",\n\\\"max_age\\\":604800}\"],\n\"Server\":[\"cloudflare\"]}} {\n\"correlation_id\":\"95236535-ec98-4541-b92a-adfa73b69eab\",\n\"trace_id\":\"c7ab8365-903f-46d4-9403-0e5b551e3545\"}","role_description":"text entry area","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Code changed:","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.088194445,"height":0.027777778},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Sync Changes","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Hide This Notification","depth":4,"bounds":{"left":0.0,"top":0.0,"width":0.018055556,"height":0.026666667},"on_screen":false,"role_description":"button","is_enabled":false,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXStaticText","text":"7","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"48","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"33","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXStaticText","text":"1","depth":4,"on_screen":true,"role_description":"text"},{"role":"AXButton","text":"Previous Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXButton","text":"Next Highlighted Error","depth":4,"on_screen":true,"role_description":"button","is_enabled":true,"is_focused":false,"is_selected":false,"is_expanded":false},{"role":"AXTextArea","text":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","depth":4,"on_screen":true,"value":"<?php\n\nnamespace Jiminny\\Services\\Crm\\Hubspot;\n\nuse Carbon\\Carbon;\nuse Exception;\nuse Generator;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Illuminate\\Support\\Facades\\Cache;\nuse InvalidArgumentException;\nuse Jiminny\\Contracts\\Repositories\\TeamRepository;\nuse Jiminny\\Contracts\\Services\\Crm\\ClientInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\FetchRelatedActivityInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\LayoutManagementInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\MatchCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\Provider\\HubspotInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityLookupInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\RemoteEntityManipulationInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SavePlaybackLinkToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SendSummaryToCrmInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SettingsInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmEntitiesInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\SyncCrmMetadataInterface;\nuse Jiminny\\Contracts\\Services\\Crm\\VerifyTaskExistsInterface;\nuse Jiminny\\Exceptions\\CrmException;\nuse Jiminny\\Exceptions\\HttpNotFoundException;\nuse Jiminny\\Jobs\\Crm\\NoteObject;\nuse Jiminny\\Models\\Account;\nuse Jiminny\\Models\\Activity;\nuse Jiminny\\Models\\Contact;\nuse Jiminny\\Models\\Contracts\\ActivityContract;\nuse Jiminny\\Models\\Crm\\BusinessProcess;\nuse Jiminny\\Models\\Crm\\Field;\nuse Jiminny\\Models\\Crm\\FieldData;\nuse Jiminny\\Models\\Crm\\Layout;\nuse Jiminny\\Models\\Crm\\Profile;\nuse Jiminny\\Models\\Lead;\nuse Jiminny\\Models\\Opportunity;\nuse Jiminny\\Models\\Participant;\nuse Jiminny\\Models\\Playbook;\nuse Jiminny\\Models\\SocialAccount;\nuse Jiminny\\Models\\Stage;\nuse Jiminny\\Models\\User;\nuse Jiminny\\Repositories\\Crm\\CrmEntityRepository;\nuse Jiminny\\Repositories\\Crm\\FieldRepository;\nuse Jiminny\\Repositories\\Crm\\ProfileRepository;\nuse Jiminny\\Repositories\\ParticipantRepository;\nuse Jiminny\\Services\\Avatar\\ProspectPhotoPathService;\nuse Jiminny\\Services\\Crm\\BaseService;\nuse Jiminny\\Services\\Crm\\Hubspot\\Actions\\SyncArchivedProfilesAction;\nuse Jiminny\\Services\\Crm\\Hubspot\\Fields\\ValueNormalizer;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\OpportunitySyncTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncCrmEntitiesTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\SyncFieldsTrait;\nuse Jiminny\\Services\\Crm\\Hubspot\\ServiceTraits\\WriteCrmTrait;\nuse Jiminny\\Services\\Crm\\MatchDomainByEmailInterface;\nuse Jiminny\\Services\\Crm\\OpportunitySyncStrategyResolver;\nuse Jiminny\\Services\\Crm\\ResolveCompanyNameByEmailTrait;\nuse Jiminny\\Utils\\PlaybackUrlBuilder;\nuse Sentry;\nuse SevenShores\\Hubspot\\Exceptions\\BadRequest;\nuse Throwable;\nuse UnexpectedValueException;\n\n/**\n * @phpstan-type CrmFieldDefinition array{\n * name: string,\n * label: string,\n * description: string,\n * type: string,\n * fieldType: string,\n * hidden: bool,\n * showCurrencySymbol: bool,\n * options: array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }\n */\nclass Service extends BaseService implements\n HubspotInterface,\n SyncCrmEntitiesInterface,\n SyncCrmMetadataInterface,\n SendSummaryToCrmInterface,\n MatchDomainByEmailInterface,\n SavePlaybackLinkToCrmInterface,\n RemoteEntityManipulationInterface,\n FetchRelatedActivityInterface,\n LayoutManagementInterface,\n SettingsInterface,\n MatchCrmEntitiesInterface,\n RemoteEntityLookupInterface,\n VerifyTaskExistsInterface\n{\n use ResolveCompanyNameByEmailTrait;\n use SyncCrmEntitiesTrait;\n use WriteCrmTrait;\n use SyncFieldsTrait;\n use OpportunitySyncTrait;\n\n private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;\n\n private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';\n private const int BATCH_UPDATE_LIMIT = 100;\n private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';\n private const int TEN_SECONDLY_ROLLING_LIMIT = 10;\n private const string CALLS_SEARCH_ENDPOINT = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n\n private const string TYPE_NOTE = 'NOTE';\n\n private const string TYPE_MEETING = 'MEETING';\n\n private const string TYPE_CALL = 'CALL';\n\n private const string API_URL = 'https://api.hubapi.com';\n\n // NB: v1 is legacy - v3 is the newest\n private const string ENDPOINT_PIPELINES = '/crm-pipelines/v1/pipelines/';\n private const string PIPELINE_OBJECT_TYPE_DEALS = 'deals';\n\n private const int TASK_VERIFICATION_CACHE_TTL = 86400; // 1 day\n\n /**\n * @var ClientInterface|Client\n */\n protected $client;\n protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;\n protected CrmEntityRepository $crmEntityRepository;\n protected ProspectPhotoPathService $prospectPhotoPathService;\n\n private SyncFieldAction $syncFieldAction;\n private PayloadBuilder $payloadBuilder;\n private SyncRelatedActivityManager $syncRelatedActivityManager;\n private SyncArchivedProfilesAction $syncArchivedProfilesAction;\n private WebhookSyncBatchProcessor $batchProcessor;\n\n public function __construct(\n Client $client,\n SyncFieldAction $syncFieldAction,\n PayloadBuilder $payloadBuilder,\n ProspectPhotoPathService $prospectPhotoPathService,\n SyncArchivedProfilesAction $syncArchivedProfilesAction,\n WebhookSyncBatchProcessor $batchProcessor,\n ) {\n parent::__construct();\n\n $this->client = $client;\n $this->syncFieldAction = $syncFieldAction;\n $this->prospectPhotoPathService = $prospectPhotoPathService;\n $this->payloadBuilder = $payloadBuilder;\n $this->syncArchivedProfilesAction = $syncArchivedProfilesAction;\n $this->batchProcessor = $batchProcessor;\n $this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [\n 'client' => $this->client,\n ]);\n $this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [\n 'client' => $this->client,\n 'payloadBuilder' => $this->payloadBuilder,\n 'logger' => $this->logger,\n ]);\n $this->crmEntityRepository = app(CrmEntityRepository::class);\n $this->dealFieldsService = app(DealFieldsService::class);\n }\n\n public function getDisplayName(): string\n {\n return 'HubSpot';\n }\n\n protected function getOAuthAccount(User $user): ?SocialAccount\n {\n // In this case, the Account Owner is always the connection for any API operations.\n $owner = $user->team->owner;\n\n return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);\n }\n\n public function getClient(): Client\n {\n /** @var Client */\n return $this->client;\n }\n\n /**\n * Convert raw field data into a format compatible with CRM APIs.\n *\n * @param bool $internal Direction of the conversion.\n * True is pulling from CRM, false normalize before sending to CRM.\n */\n public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string\n {\n return ValueNormalizer::normalize(\n fieldType: $fieldType,\n fieldValue: $fieldValue,\n isInbound: $internal,\n );\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultFields(string $activityType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n $defaultFields = FieldDefinitions::defaultTaskFields();\n\n // This lazy creates these fields if not already setup.\n foreach ($defaultFields as $defaultField) {\n $fields[] = $this->config->fields()->firstOrCreate($defaultField);\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityField(string $activityType): Field\n {\n /** @var Field $activityField */\n $activityField = $this->config->fields()->where([\n 'crm_provider_id' => 'activityType',\n 'object_type' => $activityType,\n ])->first();\n\n return $activityField;\n }\n\n /**\n * @inheritdoc\n */\n public function getSupportedPlaybookTypes(): array\n {\n return [Playbook::ACTIVITY_TYPE_TASK];\n }\n\n /**\n * @inheritdoc\n */\n public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array\n {\n $fields = [];\n\n if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {\n // Outcome should always be provided calls/meetings.\n $fieldData = [\n [\n 'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',\n 'object_type' => Field::OBJECT_TASK,\n ],\n ];\n\n foreach ($fieldData as $data) {\n $field = $this->config->fields()->where($data)->first();\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n }\n\n return $fields;\n }\n\n public function getDealInsightsFields(): array\n {\n return FieldDefinitions::dealInsightsFields();\n }\n\n protected function getDefaultFollowupLayoutFields(string $activityType): array\n {\n $fields = [];\n $fieldRepo = app(FieldRepository::class);\n $fieldData = FieldDefinitions::followupFieldsFilter();\n\n foreach ($fieldData as $data) {\n $field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);\n\n // Only add the field if it is created, which it should be.\n if ($field) {\n $fields[] = $field;\n }\n }\n\n return $fields;\n }\n\n /**\n * @inheritdoc\n */\n public function syncField(Field $field): void\n {\n switch ($field->object_type) {\n case Field::OBJECT_ACCOUNT:\n $crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_CONTACT:\n $crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_OPPORTUNITY:\n $crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);\n\n break;\n case Field::OBJECT_TASK:\n $this->syncSingleTaskField($field);\n\n return;\n default:\n return;\n }\n\n $this->syncFieldAction->execute($field, $crmField->toArray());\n }\n\n /**\n * @param array<array{\n * id:string,\n * label:string,\n * value?:string\n * }> $options\n *\n * @throws CrmException\n *\n * @return FieldData[]\n *\n */\n public function importPicklistValues(\n Field $field,\n array $options = [['id' => '', 'label' => '', 'value' => '']],\n ): array {\n if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {\n // We already have the options, no need to fetch them again\n return $this->importOptions($field, $options);\n }\n\n $options = [];\n\n switch ($field->getObjectType()) {\n case Field::OBJECT_ACCOUNT:\n $options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_CONTACT:\n $options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());\n\n break;\n\n case Field::OBJECT_OPPORTUNITY:\n // Hubspot has different endpoint for stages\n $options = $this->getClient()->fetchOpportunityFieldOptions($field);\n\n break;\n\n case Field::OBJECT_TASK:\n if ($field->getCrmProviderId() === 'disposition') {\n $options = $this->getClient()->fetchDispositionFieldOptions();\n } elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {\n $options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);\n }\n\n break;\n\n default:\n $this->logger->warning('Invalid object type', [\n 'object_type' => $field->getObjectType(),\n 'field_id' => $field->getId(),\n ]);\n\n throw new CrmException('Invalid object type');\n }\n\n return $this->importOptions($field, $options);\n }\n\n /**\n * @inheritdoc\n */\n public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage\n {\n $missingStage = null;\n\n try {\n // Use the HubSpot API client instead of the SDK crmPipelines() method\n $endpoint = self::getDealsPipelinesEndpoint();\n $pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);\n $pipelines = $pipelinesResponse->data->results;\n } catch (RequestException|BadRequest $exception) {\n throw $exception;\n }\n\n foreach ($pipelines as $pipeline) {\n $stages = [];\n\n // We create a business process to contain the pipeline, and store all stages against it.\n $p = ResponseNormalize::normalizePipeline($pipeline);\n\n // Create/update business process for this pipeline\n $businessProcess = $this->config->businessProcesses()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'type' => BusinessProcess::TYPE_OPPORTUNITY,\n 'is_selectable' => $p['active'],\n ]);\n\n // A record type is really a clone of the business process, used to store which record uses which pipeline.\n // Create/update record type clone\n $this->config->recordTypes()->updateOrCreate([\n 'crm_provider_id' => $p['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($p['label'], 0, 150),\n 'is_selectable' => $p['active'],\n 'business_process_id' => $businessProcess->id ?? null,\n ]);\n\n // Stages - fetch all existing stages upfront to avoid N+1 queries\n $existingStages = $this->config->stages()\n ->withTrashed()\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->get()\n ->keyBy('crm_provider_id');\n\n foreach ($p['stages'] as $dealStage) {\n $s = ResponseNormalize::normalizeDealStage($dealStage);\n\n /** @var ?Stage $existingStage */\n $existingStage = $existingStages->get($s['id']);\n\n // Restore soft-deleted stages that are now active in HubSpot\n if ($existingStage?->trashed() && $s['active']) {\n $existingStage->restore();\n }\n\n // Upsert stage (updates soft-deleted records without restoring them)\n $stage = $this->config->stages()->withTrashed()->updateOrCreate([\n 'crm_provider_id' => $s['id'],\n ], [\n 'team_id' => $this->team->id,\n 'name' => mb_strimwidth($s['label'], 0, 50),\n 'label' => mb_strimwidth($s['label'], 0, 191),\n 'type' => Stage::TYPE_OPPORTUNITY,\n 'sequence' => $s['displayOrder'],\n 'is_selectable' => $s['active'],\n 'probability' => $s['probability'] * 100,\n ]);\n\n if ($missingStageName === $s['id']) {\n $missingStage = $stage;\n }\n\n $stages[] = $stage->id;\n }\n\n $businessProcess->stages()->sync($stages);\n }\n\n return $missingStage;\n }\n\n /**\n * @inheritdoc\n */\n public function syncOrganization(): void\n {\n try {\n $endpoint = 'https://api.hubapi.com/integrations/v1/me';\n $response = $this->client->getInstance()->getClient()->request('get', $endpoint);\n\n $accountData = $response->data;\n $this->config->update(['default_currency' => $accountData->currency]);\n } catch (BadRequest $e) {\n throw new CrmException('Could not sync the organization.', $e->getCode(), $e);\n }\n }\n\n /**\n * @inheritdoc\n *\n * @throws CrmException\n */\n public function syncProfiles(?User $userToSearch = null): ?Profile\n {\n $this->syncArchivedProfilesAction->execute($this->team, $this->client, $this->config);\n\n try {\n $owners = $this->client->getOwners();\n } catch (\\HubSpot\\Client\\Crm\\Owners\\ApiException $e) {\n $this->logger->error('[HubSpot] Could not sync the profiles.', [\n 'team_id' => $this->team->getId(),\n 'reason' => $e->getMessage(),\n ]);\n\n throw new CrmException('Could not sync the profiles.', $e->getCode(), $e);\n }\n\n $profileRepository = app(ProfileRepository::class);\n $teamRepository = app(TeamRepository::class);\n\n foreach ($owners as $owner) {\n if ($owner->getArchived()) {\n // not supposed to fetch archived, but log anyway\n $this->logger->warning('[HubSpot] Found archived owner', [\n 'crm_provider_id' => $owner->getId(),\n 'email' => $owner->getEmail(),\n ]);\n\n continue;\n }\n\n $email = $owner->getEmail();\n if ($email === null) {\n continue;\n }\n\n $user = $teamRepository->findActiveTeamMemberByEmail($this->team, $email);\n\n if (! $user instanceof User) {\n continue;\n }\n\n $profile = $profileRepository->updateOrCreateProfile($user, [\n 'crm_configuration_id' => $this->config->getId(),\n 'crm_provider_id' => $owner->getId(),\n ]);\n\n if ($userToSearch && $userToSearch->getId() === $user->getId()) {\n return $profile;\n }\n }\n\n return null;\n }\n\n private function generateNameSearchPayload(string $name, int $offset, int $limit): array\n {\n $payload = [\n 'query' => $name,\n 'sorts' => [\n [\n 'propertyName' => 'modifieddate',\n 'direction' => 'DESCENDING',\n ],\n ],\n 'properties' => [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n 'industry',\n 'name',\n 'company',\n ],\n 'limit' => $limit,\n 'after' => $offset,\n ];\n\n $this->logger->debug('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n return $payload;\n }\n\n /**\n * @inheritdoc\n */\n public function find(string $name, array $scopes): array\n {\n $count = $this->limit ?? 20;\n $offset = $this->offset ?? 0;\n\n /** @var array<int, array<string, mixed>> */\n return Cache::remember(\n key: $this->team->getId() . $name . $count . $offset,\n ttl: 300,\n callback: function () use ($name, $offset, $count): array {\n $data = [];\n\n // Use the new V3 API to find contacts based on additional fields.\n foreach (['companies', 'contacts'] as $objectType) {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/' . $objectType . '/search';\n $payload = $this->generateNameSearchPayload($name, $offset, $count);\n $type = $objectType === 'companies' ? 'account' : 'contact';\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, [\n 'json' => $payload,\n ]);\n\n // Build mapped list.\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n\n $objectName = $this->buildContactName($properties);\n\n $record = [\n 'crmId' => $object['id'],\n // Pass crmUrl to the FE, needed for success message in the extension when you log activity.\n 'crmUrl' => $this->generateProviderUrl($object['id'], $type),\n 'name' => $objectName,\n 'prospectType' => $type,\n 'phoneNumbers' => [],\n ];\n\n if ($type === 'account') {\n $record['industry'] = $properties['industry'] ?? null;\n } else {\n $record['title'] = $properties['jobtitle'] ?? null;\n $record['organization'] = $properties['company'] ?? null;\n }\n\n $countryCode = $this->buildContactCountry($properties);\n $parsedNumber = $this->buildContactPhone($countryCode, $properties);\n\n // Add phone number to record.\n if (! empty($parsedNumber['phone'])) {\n $record['phoneNumbers'][] = [\n 'number' => $parsedNumber['phone'],\n 'nationalFormat' => phone_national($countryCode, $parsedNumber['phone']),\n 'type' => 'phone',\n ];\n }\n\n // Add mobile phone number to record.\n if (! empty($properties['mobilephone'])) {\n $mobileNumber = phone_e164($countryCode, $properties['mobilephone']);\n if ($mobileNumber !== null) {\n $record['phoneNumbers'][] = [\n 'number' => $mobileNumber,\n 'nationalFormat' => phone_national($countryCode, $mobileNumber),\n 'type' => 'mobile',\n ];\n }\n }\n\n $data[] = $record;\n }\n } catch (BadRequest $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->getUuid(),\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n\n throw $e;\n }\n }\n\n return $data;\n },\n );\n }\n\n\n /**\n * @inheritdoc\n */\n public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array\n {\n $data = [];\n $ownerData = [];\n $ownerId = null;\n\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 $closedStages = $this->getClosedDealStages();\n $payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(\n $this->config,\n $crmAccountId,\n $closedStages,\n );\n\n $results = $this->client->getPaginatedData($payload, 'deals');\n\n foreach ($results['results'] as $object) {\n $properties = $object['properties'];\n\n $amount = null;\n if (empty($properties['amount']) === false) {\n $currency = $properties['deal_currency_code'] ?? $this->config->default_currency;\n\n // Values can contain commas and any junk so strip them.\n $value = (float) preg_replace('/[^\\d.]/', '', $properties['amount']);\n $amount = formatCurrency($value, $currency);\n }\n\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n\n if ($businessProcess === null) {\n // Import it.\n $stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);\n $businessProcess = $this->config\n ->businessProcesses()\n ->where('crm_provider_id', $properties['pipeline'])\n ->first();\n } else {\n $stage = $businessProcess\n ->stages()\n ->where('crm_provider_id', $properties['dealstage'])\n ->where('type', Stage::TYPE_OPPORTUNITY)\n ->first();\n\n if ($stage === null) {\n // Import it.\n $stage = $this->importStages(null, $properties['dealstage']);\n }\n }\n\n $recordType = null;\n if ($businessProcess) {\n $recordType = $businessProcess->recordTypes()->first();\n }\n\n $isWon = in_array($properties['dealstage'], $closedStages['won']);\n $isLost = in_array($properties['dealstage'], $closedStages['lost']);\n\n $record = [\n 'crmId' => $object['id'],\n 'name' => $properties['dealname'] ?? 'Unknown Deal',\n 'value' => $amount,\n 'won' => $isWon,\n 'closed' => $isWon || $isLost,\n 'stage' => [\n 'id' => $stage?->getUuid() ?? '',\n 'name' => $stage?->getName() ?? '',\n ],\n ];\n\n if ($recordType) {\n $record += [\n 'recordType' => [\n 'id' => $recordType->id_string,\n 'name' => $recordType->name,\n ],\n ];\n }\n\n if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {\n $ownerData[] = $record;\n }\n\n $data[] = $record;\n }\n\n if (! empty($ownerData)) {\n return $ownerData;\n }\n\n return $data;\n }\n\n /**\n * @inheritdoc\n */\n public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array\n {\n $data = [];\n switch ($objectType) {\n case 'contact':\n $hsObject = 'contact';\n\n break;\n case 'account':\n $hsObject = 'company';\n\n break;\n default:\n // This is a hack to prioritise and override a contact/company with a deal.\n if ($opportunityId) {\n $hsObject = 'deal';\n $objectId = $opportunityId;\n } else {\n throw new InvalidArgumentException('Object type not supported.');\n }\n }\n\n $engagementTypes = ['meetings', 'tasks'];\n\n foreach ($engagementTypes as $engagementType) {\n $payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);\n\n $this->logger->info('[HubSpot] CRM Search requested', [\n 'request' => $payload,\n ]);\n\n $engagements = $this->client->getPaginatedData($payload, $engagementType);\n\n foreach ($engagements['results'] as $engagement) {\n if ($engagementType == 'meetings') {\n $title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';\n } elseif ($engagementType == 'tasks') {\n $title = $engagement['properties']['hs_task_subject'];\n } else {\n $title = 'Scheduled meeting';\n }\n\n $data[] = [\n 'crmId' => $engagement['id'],\n 'subject' => $title,\n 'due' => $engagement['properties']['hs_timestamp'],\n 'type' => $engagement['properties']['hs_activity_type'] ?? null,\n ];\n }\n }\n\n usort($data, function ($item1, $item2) {\n return $item2['due'] <=> $item1['due'];\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 $contactProperties = [\n 'email',\n 'firstname',\n 'lastname',\n 'country',\n 'phone',\n 'mobilephone',\n 'jobtitle',\n 'hubspot_owner_id',\n 'associatedcompanyid',\n 'photo',\n ];\n $contact = null;\n $account = null;\n\n try {\n $hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);\n\n if ($hsContact) {\n $contact = $this->importContact($hsContact);\n $account = $contact->account;\n }\n\n $data = $this->convertCrmData($contact, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : null;\n } catch (BadRequest $e) {\n $this->logger->warning('[HubSpot] Search failed', [\n 'team_id' => $this->team->getId(),\n 'search_identifier' => $email,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return null;\n }\n\n public function getDomain(string $email): ?string\n {\n return $this->getDomainFromEmail($email);\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 // Try to find a company matching their email domain.\n $companyProperties = [\n 'country',\n 'phone',\n 'name',\n 'hs_avatar_filemanager_key',\n 'industry',\n 'hubspot_owner_id',\n 'domain',\n ];\n\n try {\n $hsAccounts = $this->client\n ->getInstance()\n ->companies()\n ->searchByDomain($companyName, $companyProperties);\n } catch (Throwable $e) {\n $this->logger->info('[HubSpot] Search failed', [\n 'error' => $e->getMessage(),\n 'domain' => $domain,\n ]);\n\n return null;\n }\n\n $account = null;\n // If there are multiple accounts, don't guess, we'll ask later.\n if (\\count($hsAccounts->data->results) === 1) {\n // Persist this remote object.\n $account = $this->syncAccount($hsAccounts->data->results[0]->companyId);\n }\n\n $data = $this->convertCrmData(null, $account, $userId);\n\n return ! empty(array_filter($data)) ? $data : 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(?Contact $contact, ?Account $account, ?int $userId = null): array\n {\n $countryCode = null;\n if ($contact && $contact->country_code) {\n $countryCode = $contact->country_code;\n } elseif ($account && $account->country_code) {\n $countryCode = $account->country_code;\n }\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact ? $contact->crm_provider_id : null,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n // If there are multiple opportunities, don't guess, we'll ask later.\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n }\n\n protected function getCacheKey(string $object, ?int $userId = null): ?string\n {\n $key = $this->team->getId() . $object;\n $keySuffix = $this->getOwnerKeySuffix($userId);\n\n return $key . $keySuffix;\n }\n\n private function getOwnerKeySuffix(?int $userId = null): string\n {\n return $userId === null ? '' : (string) $userId;\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n *}\n */\n public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array\n {\n if (str_contains($phone, '**')) {\n return null;\n }\n\n // trim all whitespaces if present so the lookup doesn't fail\n $phone = str_replace(' ', '', $phone);\n\n // Check if the user is internal.\n if ($this->isPhoneNumberOfTeamMember($phone)) {\n return null;\n }\n\n $response = $this->searchForPhoneNumber($phone);\n if (empty($response)) {\n return null;\n }\n\n // This would ideally importContact instead but the response type differs.\n $contact = $this->findAndSyncContact($response['results'][0]['id']);\n if (! $contact instanceof Contact) {\n return null;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account?->crm_provider_id,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n\n try {\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n } catch (Exception $e) {\n $this->logger->debug('[HubSpot] Opportunity failed to sync.', [\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\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 private function findAndSyncContact(string $crmId): ?Contact\n {\n try {\n return $this->syncContact($crmId);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'reason' => $exception->getMessage(),\n ]);\n\n return null;\n }\n }\n\n private function hasResults(array $response): bool\n {\n return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;\n }\n\n private function searchForPhoneNumber(string $phone): array\n {\n // Normalizes the provided phone number for the API search.\n $normalizedPhone = $this->normalizePhoneNumber($phone);\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);\n\n $this->logger->info('[HubSpot] Phone match search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);\n\n if (! $this->hasResults($response)) {\n $nationalPhone = preg_replace('/\\D/', '', phone_national(null, $phone));\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);\n\n $this->logger->info('[HubSpot] Phone match national number search triggered', [\n 'phone' => $phone,\n 'nationalPhone' => $nationalPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n if (! $this->hasResults($response)) {\n $payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);\n\n $this->logger->info('[HubSpot] Phone match alternative search triggered', [\n 'phone' => $phone,\n 'normalizedPhone' => $normalizedPhone,\n 'payload' => $payload,\n ]);\n\n $response = $this->handlePhoneSearchRequest($phone, $payload);\n }\n\n return $this->hasResults($response) ? $response : [];\n }\n\n private function handlePhoneSearchRequest(string $phone, array $payload): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/contacts/search';\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n $endpoint,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Phone match failed', [\n 'phone' => $phone,\n 'reason' => $exception->getMessage(),\n ]);\n\n return [];\n }\n\n $this->logger->info('[HubSpot] Phone match completed', [\n 'phone' => $phone,\n 'response' => $response,\n ]);\n\n return $response->toArray();\n }\n\n private function normalizePhoneNumber(string $phone): string\n {\n return ltrim(phone_e164(null, $phone), '+0');\n }\n\n /**\n * @return null|array{\n * Lead|null,\n * Account|null,\n * Opportunity|null,\n * Contact|null,\n * Stage|null,\n * string|null\n * }\n */\n public function matchByName(string $name, ?int $userId = null): ?array\n {\n // Don't waste time searching for single character strings.\n if (\\strlen($name) <= 1) {\n return null;\n }\n\n $cacheKey = $this->getCacheKey($name, $userId);\n\n $result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {\n $payload = $this->payloadBuilder->generateSearchContactsByNamePayload(\n $name,\n $this->getContactFields()\n );\n\n $hsContacts = $this->client->getPaginatedData($payload, 'contact');\n if (empty($hsContacts['results'])) {\n return false;\n }\n\n $contact = $this->importContact($hsContacts['results'][0]);\n if ($contact === null) {\n return false;\n }\n\n $account = $contact->account;\n $countryCode = $contact->country_code ?? $account->country_code ?? null;\n\n try {\n $hsOpportunities = $this->findOpportunities(\n $account ? $account->crm_provider_id : null,\n $contact->crm_provider_id,\n $userId\n );\n } catch (Exception $e) {\n $hsOpportunities = [];\n }\n\n $opportunity = null;\n $stage = null;\n if (! empty($hsOpportunities)) {\n // Persist this remote object.\n $opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);\n $stage = $opportunity?->getStage();\n }\n\n return [\n null,\n $account,\n $opportunity,\n $contact,\n $stage,\n $countryCode,\n ];\n });\n\n return is_array($result) ? $result : null;\n }\n\n\n private function convertActivityAssociations(Activity $activity): array\n {\n return [\n 'contactIds' => $this->getParticipantsIds($activity),\n 'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],\n 'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],\n 'ownerIds' => [],\n ];\n }\n\n private function getParticipantsIds(Activity $activity): array\n {\n $attendees = [];\n\n $participantRepository = app(ParticipantRepository::class);\n $participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);\n foreach ($participants as $participant) {\n if ($participant->user_id || $participant->isCoach()) {\n continue;\n }\n\n $contact = $participant->contact()->first();\n if ($contact && $contact->crm_provider_id) {\n $attendees[] = $contact->crm_provider_id;\n } else {\n if (! empty($participant->name)) {\n $attendeeData = $this->fetchMissingAttendeeInfo($participant);\n }\n if (! empty($attendeeData['id'])) {\n $attendees[] = $attendeeData['id'];\n }\n }\n }\n\n if ($activity->hasContact()) {\n $attendees[] = $activity->contact->crm_provider_id;\n }\n\n return array_unique($attendees);\n }\n\n private function fetchMissingAttendeeInfo(Participant $participant): array\n {\n // Check if we need to look inside an account context.\n $activity = $participant->getActivity();\n $companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;\n\n // First check the local data.\n /** @var Contact[] $contacts */\n $contacts = $this->team->contacts()\n ->with('account')\n ->where('name', $participant->name)\n ->whereNotNull('email')\n ->get();\n\n foreach ($contacts as $contact) {\n // If we have a company in scope, check the contact is associated to it.\n if (\n $companyId !== null\n && ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)\n ) {\n continue;\n }\n\n return [\n 'id' => $contact->crm_provider_id,\n 'email' => $contact->email,\n ];\n }\n\n $payload = $this->generateNameSearchPayload($participant->name, 0, 20);\n\n try {\n $response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);\n\n // TODO add some logic to choose the most suitable contact if multiple\n foreach ($response['results'] as $object) {\n $properties = $object['properties'];\n if (empty($object['properties']) === false) {\n // Check the company matches the contact.\n // Todo: Move this check inside the API search.\n if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {\n continue;\n }\n\n return [\n 'id' => $object['id'],\n 'email' => $properties['email'],\n ];\n }\n }\n } catch (Exception $e) {\n $this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [\n 'teamId' => $this->team->id_string,\n 'request' => $payload,\n 'reason' => $e->getMessage(),\n ]);\n }\n\n return [];\n }\n\n /**\n * Store transcripts as note engagement.\n *\n * @throws Exception\n */\n public function createTranscriptNotes(Activity $activity): void\n {\n // For HS no need to check if Crm profile - Log Notes field is enabled\n // We only check if store_transcript toggle is enabled on crm profile.\n $engagement = [\n 'active' => true,\n 'ownerId' => $this->profile->crm_provider_id,\n 'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,\n 'type' => 'NOTE',\n ];\n\n // Generate activity transcription.\n $transcriptionData = $this->generateTranscription($activity);\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);\n\n $metadata = [\n 'body' => $transcripts,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsEngagement = $this->client\n ->getInstance()\n ->engagements()\n ->create($engagement, $associations, $metadata);\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $noteId = $hsEngagement->data->engagement->id;\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 /*\n * @inheritdoc\n */\n public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void\n {\n $payload = [\n 'properties' => $data,\n ];\n\n try {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n $this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);\n\n break;\n case FieldData::OBJECT_CONTACT:\n $this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_ACCOUNT:\n $this->client->getNewInstance()->crm()->companies()->basicApi()->update($objectId, $payload);\n\n break;\n\n case FieldData::OBJECT_TASK:\n // Endpoint for Engagements not ready\n $engagements = [\n 'type' => 'TASK',\n ];\n $metadata = $data;\n $this->client->getInstance()->engagements()->update($objectId, $engagements, $metadata);\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $objectId],\n $metadata,\n );\n\n break;\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n } catch (\\HubSpot\\Client\\Crm\\Deals\\ApiException $apiException) {\n $errorMessage = $apiException->getMessage();\n if ($apiException->getResponseBody()) {\n $responseBody = json_decode($apiException->getResponseBody(), true, 512, JSON_THROW_ON_ERROR);\n $errorMessage = $responseBody['message'] ?? $apiException->getMessage();\n }\n\n $this->logger->error(\n '[HubSpot] Update record failed',\n [\n 'objectType' => $objectType,\n 'objectId' => $objectId,\n 'payload' => $payload,\n 'reason' => $errorMessage,\n 'team' => $this->team->getUuid(),\n ]\n );\n\n throw new CrmException($errorMessage);\n }\n }\n\n /*\n * @inheritdoc\n */\n public function getRecord(string $objectType, string $objectId, array $fields = []): array\n {\n switch ($objectType) {\n case FieldData::OBJECT_OPPORTUNITY:\n return $this->client->getInstance()->deals()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_CONTACT:\n return $this->client->getInstance()->contacts()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_ACCOUNT:\n return $this->client->getInstance()->companies()->getById($objectId)->toArray();\n\n case FieldData::OBJECT_TASK:\n return $this->client->getInstance()->engagements()->get($objectId)->toArray();\n\n default:\n throw new UnexpectedValueException('Unsupported object type \"' . $objectType . '\"');\n }\n }\n\n /**\n * @throws BadRequest\n * @throws CrmException\n */\n public function updateStage($crmObject, Stage $stage): void\n {\n $payload = [\n 'properties' => [\n [\n 'name' => 'dealstage',\n 'value' => $stage->crm_provider_id,\n ],\n ],\n ];\n\n try {\n $this->client->getInstance()->deals()->update($crmObject->crm_provider_id, $payload);\n } catch (BadRequest $badRequest) {\n if ($badRequest->getCode() === 403) {\n throw new CrmException(\n \"Sorry, you don't have permission to update this stage.\",\n $badRequest->getCode(),\n $badRequest,\n );\n }\n\n $this->logger->warning('[HubSpot] Stage update failed', [\n 'dealId' => $crmObject->crm_provider_id,\n 'payload' => $payload,\n 'message' => $badRequest->getMessage(),\n ]);\n\n throw $badRequest;\n }\n }\n\n public function generateProviderUrl(string $providerId, string $objectType): ?string\n {\n $url = null;\n $baseUrl = 'https://app.hubspot.com/contacts/' . $this->config->crm_provider_id . '/';\n\n switch ($objectType) {\n case 'account':\n $url = $baseUrl . 'company/' . $providerId;\n\n break;\n\n case 'contact':\n $url = $baseUrl . 'contact/' . $providerId;\n\n break;\n\n case 'opportunity':\n $url = $baseUrl . 'deal/' . $providerId;\n\n break;\n\n case 'task':\n case 'activity':\n return null;\n\n // This should not be deep-linked as per JMNY-3934.\n //$url = $baseUrl.'tasks/list/view/all/?taskId='.$providerId;\n break;\n }\n\n return $url;\n }\n\n public function searchCalls(Carbon $from, Carbon $to, string $activityProvider): array\n {\n $this->logger->info('[HubSpot] Search calls', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $calls = [];\n $page = 1;\n\n do {\n try {\n $payload = $this->payloadBuilder->generateGetCallsPayload($from, $to, $activityProvider, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n $calls = array_merge($calls, $responseResults);\n $page++;\n } while (! empty($responseResults));\n\n return $calls;\n }\n\n public function searchCallsForPeriodByPage(Carbon $from, Carbon $to, int $page, bool $retry = true)\n {\n try {\n $payload = $this->payloadBuilder->generateSearchCallsByPeriodPayload($from, $to, $page);\n $response = $this->client->getInstance()->getClient()->request(\n 'POST',\n self::CALLS_SEARCH_ENDPOINT,\n ['json' => ($payload)],\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search calls for period failed', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallsForPeriodByPage($from, $to, $page, false);\n }\n $response = null;\n }\n\n return $response;\n }\n\n public function searchCallsForPeriod(Carbon $from, Carbon $to): Generator\n {\n $this->logger->info('[HubSpot] Search calls for period', [\n 'from' => $from->format(self::LOG_DATE_FORMAT),\n 'to' => $to->format(self::LOG_DATE_FORMAT),\n ]);\n\n $page = 1;\n\n do {\n $response = $this->searchCallsForPeriodByPage($from, $to, $page);\n\n $responseResults = empty($response['results']) ? [] : $response['results'];\n\n $associationContacts = $this->getAssociationDataForCollection($responseResults, 'calls', 'contacts');\n $associationCompanies = $this->getAssociationDataForCollection($responseResults, 'calls', 'companies');\n $associationDeals = $this->getAssociationDataForCollection($responseResults, 'calls', 'deals');\n\n foreach ($responseResults as $call) {\n $call['associations'] = [\n 'contacts' => $this->importAssociationData($call, $associationContacts),\n 'companies' => $this->importAssociationData($call, $associationCompanies),\n 'deals' => $this->importAssociationData($call, $associationDeals),\n ];\n\n yield $call;\n }\n $page++;\n } while (! empty($responseResults));\n }\n\n public function getCall(string $callId): array\n {\n $this->logger->info('[HubSpot] Get call', [\n 'call_id' => $callId,\n ]);\n\n $searchAttributes = $this->payloadBuilder->getSearchCallAttributes();\n $endpoint = sprintf(\n 'https://api.hubapi.com/crm/v3/objects/calls/%s',\n $callId,\n );\n\n try {\n $response = $this->client->getInstance()->getClient()->request(\n 'GET',\n $endpoint,\n [],\n sprintf(\n 'properties=%s&associations=contacts,companies,deals',\n implode(',', $searchAttributes),\n ),\n );\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Get call failed', [\n 'call_id' => $callId,\n 'reason' => $exception->getMessage(),\n ]);\n $response = null;\n }\n\n return empty($response) ? [] : $response->toArray();\n }\n\n public function bulkAddPlaybackURLToDescription(array $crmUpdateData): array\n {\n $crmUpdateBatches = array_chunk($crmUpdateData, self::BATCH_UPDATE_LIMIT);\n\n $updatedCrmIds = [];\n\n foreach ($crmUpdateBatches as $crmBatch) {\n $payload = $this->payloadBuilder->generatePlaybackAddUrlBatchPayload($crmBatch);\n $updateSuccess = $this->bulkAddPlaybackURLToDescriptionRequest($payload);\n if ($updateSuccess) {\n $updatedCrmIds = array_merge($updatedCrmIds, array_column($crmBatch, 'crm_id'));\n }\n }\n\n return $updatedCrmIds;\n }\n\n private function bulkAddPlaybackURLToDescriptionRequest(array $payload, bool $retry = true): bool\n {\n try {\n $this->client->getNewInstance()->crm()->objects()->batchApi()->update('calls', $payload);\n\n return true;\n } catch (\\HubSpot\\Client\\Crm\\Objects\\ApiException $e) {\n $response = json_decode($e->getResponseBody(), true);\n $retryAfter =\n isset($response['policyName'])\n && $response['policyName'] == self::TEN_SECONDLY_ROLLING_POLICY\n ? self::TEN_SECONDLY_ROLLING_LIMIT\n : 1;\n } catch (Exception $e) {\n $retryAfter = 1;\n }\n\n $this->logger->warning('[HubSpot] Bulk add playback url to CRM failed', [\n 'reason' => $e->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep($retryAfter);\n\n return $this->bulkAddPlaybackURLToDescriptionRequest($payload, false);\n }\n\n return false;\n }\n\n /**\n * Sometimes we have secondly rate limit error, then retry request after 1 second\n */\n public function searchCallByRecordingURLToken(string $playbackURLToken, bool $retry = true): array\n {\n $endpoint = 'https://api.hubapi.com/crm/v3/objects/calls/search';\n $payload = $this->payloadBuilder->generateSearchCallByTokenPayload($playbackURLToken);\n\n $this->logger->info('[HubSpot] CRM Search by playback URL token requested', [\n 'request' => $payload,\n ]);\n\n try {\n $response = $this->client->getInstance()->getClient()->request('POST', $endpoint, ['json' => ($payload)]);\n } catch (Exception $exception) {\n $this->logger->info('[HubSpot] Search by playback URL token failed', [\n 'playbackURLToken' => $playbackURLToken,\n 'reason' => $exception->getMessage(),\n 'retry' => $retry,\n ]);\n\n if ($retry) {\n sleep(1);\n\n return $this->searchCallByRecordingURLToken($playbackURLToken, false);\n }\n\n return [];\n }\n\n return empty($response['results']) ? [] : $response['results'][0];\n }\n\n /**\n * Generate transcription for the activity.\n */\n private function generateTranscription(Activity $activity): string\n {\n if (! $this->config->store_transcript) {\n // If sending transcription to activity toggle is disabled\n return '';\n }\n\n $transcriptionSegments = $this->transcriptionService->findTranscriptionByActivity($activity);\n\n if ($transcriptionSegments->isEmpty()) {\n return '';\n }\n\n $transcription = sprintf(\n '<p><strong>Transcript for %s</strong></p><p></p>',\n $activity->title ?? $activity->activity_title,\n );\n\n $roomOwnerParticipant = $activity->findParticipantRoomOwner();\n $roomOwnerParticipantId = $roomOwnerParticipant !== null\n ? $roomOwnerParticipant->getId()\n : null;\n\n\n $transcription .= $transcriptionSegments\n ->map(static function (array $transcriptionSegment) use ($roomOwnerParticipantId): string {\n $isOrganiser = $roomOwnerParticipantId === $transcriptionSegment['participantId']\n && $roomOwnerParticipantId !== null;\n $transcriptColor = $isOrganiser ? '#000000' : '#f0415a';\n\n return sprintf(\n '<span style=\"color: %s;\">%s | </span>%s',\n $transcriptColor,\n $transcriptionSegment['formattedStartsAt'],\n $transcriptionSegment['transcript'],\n );\n })\n ->implode('<br />');\n\n return $transcription;\n }\n\n /**\n * @param array<array{\n * id: string,\n * label: string,\n * value?: string,\n * }> $options\n *\n * @return FieldData[]\n */\n private function importOptions(Field $field, array $options): array\n {\n $fieldValues = [];\n $values = [];\n $sequence = 0;\n\n foreach ($options as $option) {\n $values[] = [\n 'value' => $option['value'] ?? $option['id'],\n 'label' => substr($option['label'], 0, 255),\n 'sequence' => $sequence++,\n ];\n }\n\n $fieldsToPurge = $field->values()->get()->pluck('value')->toArray();\n\n foreach ($values as $value) {\n $value['value'] = substr($value['value'], 0, 255);\n $fieldValues[] = $field->values()->updateOrCreate([\n 'value' => $value['value'],\n ], $value);\n\n // Remove this value from the ones we are going to purge.\n if (($key = array_search($value['value'], $fieldsToPurge, false)) !== false) {\n unset($fieldsToPurge[$key]);\n }\n }\n\n // Delete the old values that are no longer used.\n $field->values()->whereIn('value', $fieldsToPurge)->delete();\n\n return $fieldValues;\n }\n\n public function saveTranscriptionSummaryAsNote(\n ActivityContract $activity,\n string $title,\n string $body,\n ?string $objectId,\n ?NoteObject $noteObject = null,\n ): ?string {\n if ($noteObject === null || $objectId === null) {\n return null;\n }\n\n /** @var User $user */\n $user = $activity->getUser();\n\n $profile = $this->assignCrmOwner($user, $activity);\n if (! $profile instanceof Profile) {\n return null;\n }\n\n $timestamp = Carbon::now($user->getTimezone())->getTimestamp() * 1000;\n $engagement = [\n 'active' => true,\n 'ownerId' => $profile->getAttribute('crm_provider_id'),\n 'timestamp' => $timestamp,\n 'type' => 'NOTE',\n ];\n\n // Truncate Notes with max notes length because transcription text could be very long.\n $body = mb_strimwidth($body, 0, self::ENGAGEMENT_BODY_MAX_LENGTH);\n $metadata = [\n 'body' => $body,\n ];\n\n $associations = $this->convertActivityAssociations($activity);\n\n try {\n $hsActivityId = $this->client->createNote(\n body: $body,\n ownerId: $profile->getCrmProviderId(),\n timestamp: $timestamp,\n objectId: $objectId,\n noteObject: $noteObject,\n );\n\n $this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);\n\n $this->logger->info('[HubSpot] Saving Transcription Summary as Note', [\n 'activity' => $activity->getUuid(),\n 'crmActivity' => $hsActivityId,\n ]);\n\n return $hsActivityId;\n } catch (Exception $e) {\n Sentry::captureException($e);\n }\n\n return null;\n }\n\n public function attachSummaryToActivity(ActivityContract $activity, string $summaryTitle, string $summaryContents): bool\n {\n $this->logger->info('[HubSpot] Attaching summary to activity', [\n 'activity' => $activity->getUuid(),\n 'summary_content' => $summaryContents,\n ]);\n\n if (! $activity instanceof Activity) {\n throw new InvalidArgumentException('Expected instance of Activity');\n }\n\n $summary = '<p><strong>' . $summaryTitle . '</strong></p>';\n $summary .= '<p>' . $summaryContents . '</p>';\n $metadata = $this->buildMetadataForSummaryUpdate($activity, $summary);\n\n try {\n $type = $this->matchActivityEngagementType($activity);\n $engagement = ['type' => $type];\n\n $this->client->updateEngagement($activity->getCrmProviderId(), $engagement, $metadata);\n } catch (Exception $e) {\n $this->logger->warning('[HubSpot] Update summary failed', [\n 'activity' => $activity->getUuid(),\n 'reason' => $e->getMessage(),\n ]);\n\n return false;\n }\n\n $this->logCrmEngagementManipulation(\n self::ACTION_UPDATE,\n ['crmId' => $activity->getCrmProviderId()],\n $metadata,\n );\n\n return true;\n }\n\n private function buildMetadataForSummaryUpdate(Activity $activity, string $summary): array\n {\n $descriptionField = $activity->getType() === Activity::TYPE_CONFERENCE ? 'internalMeetingNotes' : 'body';\n $engagement = $this->client->getEngagementData($activity->getCrmProviderId());\n // Meeting without internalMeetingNotes might mean it just does not have any notes;\n $description = $engagement['metadata'][$descriptionField] ?? null;\n\n if (empty($description)) {\n $data = $summary;\n } else {\n // avoid playbook url link to Jiminny being sent twice in the activity description\n $targetUrl = PlaybackUrlBuilder::build($activity);\n\n if (str_contains($description, $targetUrl)) {\n $jiminnyUrl = '<p><a href=\"' . $targetUrl . '\" title=\"Play at Jiminny\">Play at Jiminny</a></p>';\n $summary = str_replace($jiminnyUrl, '', $summary);\n\n $this->logger->info('[HubSpot] Summary modified', [\n 'activity' => $activity->getUuid(),\n 'target_url' => $jiminnyUrl,\n 'modified_summary_content' => $summary,\n ]);\n }\n\n $data = $description . '<p></p>' . $summary;\n }\n\n return [\n $descriptionField => $data,\n ];\n }\n\n public function fetchAndAssociateRelatedActivity(Activity $activity): ?Activity\n {\n return $this->syncRelatedActivityManager->fetchAndAssociateRelatedActivity($activity);\n }\n\n public function fetchRelatedActivity(Activity $activity): array\n {\n return [];\n }\n\n public function getDealsInBulk(array $dealIds): array\n {\n $payload = $this->payloadBuilder->getDealsInBulkPayload($dealIds);\n\n return $this->client->getPaginatedData($payload, 'deals');\n }\n\n /**\n * Extract deal IDs from HubSpot search response.\n *\n * @param array $hubspotResponse The raw HubSpot search API response.\n * @param bool $includeArchived Whether to include archived deals (default: false).\n *\n * @return string[] Array of deal IDs as strings.\n */\n public function extractDealIds(array $hubspotResponse, bool $includeArchived = false): array\n {\n if (empty($hubspotResponse['results'])) {\n return [];\n }\n\n return array_values(\n array_map(\n fn ($deal) => $deal['id'],\n array_filter(\n $hubspotResponse['results'],\n fn ($deal) => $includeArchived || empty($deal['archived'])\n )\n )\n );\n }\n\n public function matchActivityEngagementType(Activity $activity): string\n {\n return match ($activity->getType()) {\n Activity::TYPE_CONFERENCE => self::TYPE_MEETING,\n Activity::TYPE_SOFTPHONE, Activity::TYPE_SOFTPHONE_INBOUND => self::TYPE_CALL,\n default => self::TYPE_NOTE,\n };\n }\n\n private function assignCrmOwner(User $user, ActivityContract $activity): ?Profile\n {\n $profile = $user->getProfile();\n if ($profile instanceof Profile) {\n return $profile;\n }\n\n $this->logger->info('[HubSpot] Unable to save summary. No profile', [\n 'activity' => $activity->getUuid(),\n ]);\n\n return null;\n }\n\n private static function getDealsPipelinesEndpoint(): string\n {\n return self::API_URL . self::ENDPOINT_PIPELINES . self::PIPELINE_OBJECT_TYPE_DEALS;\n }\n\n public function verifyTaskExists(Activity $activity): bool\n {\n $crmProviderId = $activity->getCrmProviderId();\n $cacheKey = \"crm_task_exists:{$this->config->getId()}:$crmProviderId\";\n\n return Cache::remember($cacheKey, self::TASK_VERIFICATION_CACHE_TTL, function () use ($crmProviderId) {\n try {\n $engagement = $this->client->getEngagementData($crmProviderId);\n\n return ! empty($engagement);\n } catch (HttpNotFoundException|BadRequest) {\n // Engagement not found in CRM - this is expected and permanent\n $this->logger->info('[Hubspot] Engagement not found during verification', [\n 'engagement_id' => $crmProviderId,\n 'config_id' => $this->config->getId(),\n ]);\n\n return false;\n }\n // Let other exceptions (network errors, rate limits, etc.) bubble up for retry\n });\n }\n}","role_description":"text entry area","is_enabled":true,"is_focused":true,"is_selected":false,"is_expanded":false},{"role":"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}]...
|
2045079508751645345
|
-465124630364809113
|
click
|
accessibility
|
NULL
|
Project: faVsco.js, menu
JY-20725-handle-HS-search Project: faVsco.js, menu
JY-20725-handle-HS-search-rate-limit, menu
Start Listening for PHP Debug Connections
AskJiminnyReportActivityServiceTest
Run 'AskJiminnyReportActivityServiceTest'
Debug 'AskJiminnyReportActivityServiceTest'
More Actions
JetBrains AI
Search Everywhere
IDE and Project Settings
Sync Changes
Hide This Notification
Code changed:
Hide
19
Previous Highlighted Error
Next Highlighted Error
[2026-05-07 14:21:15] local.INFO: [Hubspot] DEBUG Getting headers {
"headers":{
"Date":["Thu,07 May 2026 14:21:15 GMT"],
"Content-Type":["application/json;charset=utf-8"],
"Transfer-Encoding":["chunked"],
"Connection":["keep-alive"],
"CF-Ray":["9f80deb8db60dc3a-SOF"],
"CF-Cache-Status":["DYNAMIC"],
"Strict-Transport-Security":["max-age=31536000; includeSubDomains; preload"],
"Vary":["origin,
accept-encoding"],
"access-control-allow-credentials":["false"],
"server-timing":["hcid;desc=\"019e02d0-6fd8-7812-bdba-885b7ccb3ee3\",
cfr;desc=\"9f80deb8e7c6dc3a-IAD\""],
"x-content-type-options":["nosniff"],
"x-hubspot-correlation-id":["019e02d0-6fd8-7812-bdba-885b7ccb3ee3"],
"Set-Cookie":["__cf_bm=SIUrtdQgXVrik50pdqF6hZVYKhzTnQBidvMabeCtm0Y-1778163675-[IP_ADDRESS]-rI.ZggtDKxTge5zr8_2gbBfWMQQ.ufZEXDZyHz2mBUFdzdo2gTHEsOkXMSEShjK0hGYxNhUGM1ZoBpX7BcFZcHEjA7Cs_.SMUhUnd2nYjko; path=/; expires=Thu,
07-May-26 14:51:15 GMT; domain=.hubapi.com; HttpOnly; Secure; SameSite=None"],
"Report-To":["{
\"endpoints\":[{
\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=NYAlsVTP0fYm32qrSDjxYE4sd2RWRqiSp3wHsmdEgZlzoYdxI%2BIxVpHmsKn3O%2BKVA3mFIJ2m7YRECDGSM%2BW2IYTzo6FM4%2BdUIjURO8srzKSvJgZ%2BQ6R79arKQw3uHLlX\"}],
\"group\":\"cf-nel\",
\"max_age\":604800}"],
"NEL":["{
\"success_fraction\":0.01,
\"report_to\":\"cf-nel\",
\"max_age\":604800}"],
"Server":["cloudflare"]}} {
"correlation_id":"95236535-ec98-4541-b92a-adfa73b69eab",
"trace_id":"c7ab8365-903f-46d4-9403-0e5b551e3545"}
Code changed:
Hide
Sync Changes
Hide This Notification
7
48
1
33
1
Previous Highlighted Error
Next Highlighted Error
<?php
namespace Jiminny\Services\Crm\Hubspot;
use Carbon\Carbon;
use Exception;
use Generator;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Jiminny\Contracts\Repositories\TeamRepository;
use Jiminny\Contracts\Services\Crm\ClientInterface;
use Jiminny\Contracts\Services\Crm\FetchRelatedActivityInterface;
use Jiminny\Contracts\Services\Crm\LayoutManagementInterface;
use Jiminny\Contracts\Services\Crm\MatchCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\Provider\HubspotInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityLookupInterface;
use Jiminny\Contracts\Services\Crm\RemoteEntityManipulationInterface;
use Jiminny\Contracts\Services\Crm\SavePlaybackLinkToCrmInterface;
use Jiminny\Contracts\Services\Crm\SendSummaryToCrmInterface;
use Jiminny\Contracts\Services\Crm\SettingsInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmEntitiesInterface;
use Jiminny\Contracts\Services\Crm\SyncCrmMetadataInterface;
use Jiminny\Contracts\Services\Crm\VerifyTaskExistsInterface;
use Jiminny\Exceptions\CrmException;
use Jiminny\Exceptions\HttpNotFoundException;
use Jiminny\Jobs\Crm\NoteObject;
use Jiminny\Models\Account;
use Jiminny\Models\Activity;
use Jiminny\Models\Contact;
use Jiminny\Models\Contracts\ActivityContract;
use Jiminny\Models\Crm\BusinessProcess;
use Jiminny\Models\Crm\Field;
use Jiminny\Models\Crm\FieldData;
use Jiminny\Models\Crm\Layout;
use Jiminny\Models\Crm\Profile;
use Jiminny\Models\Lead;
use Jiminny\Models\Opportunity;
use Jiminny\Models\Participant;
use Jiminny\Models\Playbook;
use Jiminny\Models\SocialAccount;
use Jiminny\Models\Stage;
use Jiminny\Models\User;
use Jiminny\Repositories\Crm\CrmEntityRepository;
use Jiminny\Repositories\Crm\FieldRepository;
use Jiminny\Repositories\Crm\ProfileRepository;
use Jiminny\Repositories\ParticipantRepository;
use Jiminny\Services\Avatar\ProspectPhotoPathService;
use Jiminny\Services\Crm\BaseService;
use Jiminny\Services\Crm\Hubspot\Actions\SyncArchivedProfilesAction;
use Jiminny\Services\Crm\Hubspot\Fields\ValueNormalizer;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\OpportunitySyncTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncCrmEntitiesTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\SyncFieldsTrait;
use Jiminny\Services\Crm\Hubspot\ServiceTraits\WriteCrmTrait;
use Jiminny\Services\Crm\MatchDomainByEmailInterface;
use Jiminny\Services\Crm\OpportunitySyncStrategyResolver;
use Jiminny\Services\Crm\ResolveCompanyNameByEmailTrait;
use Jiminny\Utils\PlaybackUrlBuilder;
use Sentry;
use SevenShores\Hubspot\Exceptions\BadRequest;
use Throwable;
use UnexpectedValueException;
/**
* @phpstan-type CrmFieldDefinition array{
* name: string,
* label: string,
* description: string,
* type: string,
* fieldType: string,
* hidden: bool,
* showCurrencySymbol: bool,
* options: array<array{
* id: string,
* label: string,
* value?: string,
* }
*/
class Service extends BaseService implements
HubspotInterface,
SyncCrmEntitiesInterface,
SyncCrmMetadataInterface,
SendSummaryToCrmInterface,
MatchDomainByEmailInterface,
SavePlaybackLinkToCrmInterface,
RemoteEntityManipulationInterface,
FetchRelatedActivityInterface,
LayoutManagementInterface,
SettingsInterface,
MatchCrmEntitiesInterface,
RemoteEntityLookupInterface,
VerifyTaskExistsInterface
{
use ResolveCompanyNameByEmailTrait;
use SyncCrmEntitiesTrait;
use WriteCrmTrait;
use SyncFieldsTrait;
use OpportunitySyncTrait;
private const int ENGAGEMENT_BODY_MAX_LENGTH = 65536;
private const string LOG_DATE_FORMAT = 'Y-m-d H:i:s';
private const int BATCH_UPDATE_LIMIT = 100;
private const string TEN_SECONDLY_ROLLING_POLICY = 'TEN_SECONDLY_ROLLING';
private const int TEN_SECONDLY_ROLLING_LIMIT = 10;
private const string CALLS_SEARCH_ENDPOINT = '[URL_WITH_CREDENTIALS] ClientInterface|Client
*/
protected $client;
protected OpportunitySyncStrategyResolver $opportunitySyncStrategyResolver;
protected CrmEntityRepository $crmEntityRepository;
protected ProspectPhotoPathService $prospectPhotoPathService;
private SyncFieldAction $syncFieldAction;
private PayloadBuilder $payloadBuilder;
private SyncRelatedActivityManager $syncRelatedActivityManager;
private SyncArchivedProfilesAction $syncArchivedProfilesAction;
private WebhookSyncBatchProcessor $batchProcessor;
public function __construct(
Client $client,
SyncFieldAction $syncFieldAction,
PayloadBuilder $payloadBuilder,
ProspectPhotoPathService $prospectPhotoPathService,
SyncArchivedProfilesAction $syncArchivedProfilesAction,
WebhookSyncBatchProcessor $batchProcessor,
) {
parent::__construct();
$this->client = $client;
$this->syncFieldAction = $syncFieldAction;
$this->prospectPhotoPathService = $prospectPhotoPathService;
$this->payloadBuilder = $payloadBuilder;
$this->syncArchivedProfilesAction = $syncArchivedProfilesAction;
$this->batchProcessor = $batchProcessor;
$this->opportunitySyncStrategyResolver = app(OpportunitySyncStrategyResolver::class, [
'client' => $this->client,
]);
$this->syncRelatedActivityManager = app(SyncRelatedActivityManager::class, [
'client' => $this->client,
'payloadBuilder' => $this->payloadBuilder,
'logger' => $this->logger,
]);
$this->crmEntityRepository = app(CrmEntityRepository::class);
$this->dealFieldsService = app(DealFieldsService::class);
}
public function getDisplayName(): string
{
return 'HubSpot';
}
protected function getOAuthAccount(User $user): ?SocialAccount
{
// In this case, the Account Owner is always the connection for any API operations.
$owner = $user->team->owner;
return $owner->getSocialAccount(SocialAccount::PROVIDER_HUBSPOT);
}
public function getClient(): Client
{
/** @var Client */
return $this->client;
}
/**
* Convert raw field data into a format compatible with CRM APIs.
*
* @param bool $internal Direction of the conversion.
* True is pulling from CRM, false normalize before sending to CRM.
*/
public function normalizeValue(string $fieldType, string $fieldValue, bool $internal = false): string
{
return ValueNormalizer::normalize(
fieldType: $fieldType,
fieldValue: $fieldValue,
isInbound: $internal,
);
}
/**
* @inheritdoc
*/
public function getDefaultFields(string $activityType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
$defaultFields = FieldDefinitions::defaultTaskFields();
// This lazy creates these fields if not already setup.
foreach ($defaultFields as $defaultField) {
$fields[] = $this->config->fields()->firstOrCreate($defaultField);
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function getDefaultActivityField(string $activityType): Field
{
/** @var Field $activityField */
$activityField = $this->config->fields()->where([
'crm_provider_id' => 'activityType',
'object_type' => $activityType,
])->first();
return $activityField;
}
/**
* @inheritdoc
*/
public function getSupportedPlaybookTypes(): array
{
return [Playbook::ACTIVITY_TYPE_TASK];
}
/**
* @inheritdoc
*/
public function getDefaultActivityLayoutFields(string $activityType, string $layoutType): array
{
$fields = [];
if ($activityType === Playbook::ACTIVITY_TYPE_TASK) {
// Outcome should always be provided calls/meetings.
$fieldData = [
[
'crm_provider_id' => $layoutType === Layout::TYPE_SOFTPHONE_SUMMARY ? 'disposition' : 'meetingOutcome',
'object_type' => Field::OBJECT_TASK,
],
];
foreach ($fieldData as $data) {
$field = $this->config->fields()->where($data)->first();
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
}
return $fields;
}
public function getDealInsightsFields(): array
{
return FieldDefinitions::dealInsightsFields();
}
protected function getDefaultFollowupLayoutFields(string $activityType): array
{
$fields = [];
$fieldRepo = app(FieldRepository::class);
$fieldData = FieldDefinitions::followupFieldsFilter();
foreach ($fieldData as $data) {
$field = $fieldRepo->findOneConfigurationFieldByProperties($this->config, $data);
// Only add the field if it is created, which it should be.
if ($field) {
$fields[] = $field;
}
}
return $fields;
}
/**
* @inheritdoc
*/
public function syncField(Field $field): void
{
switch ($field->object_type) {
case Field::OBJECT_ACCOUNT:
$crmField = $this->client->getInstance()->companyProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_CONTACT:
$crmField = $this->client->getInstance()->contactProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_OPPORTUNITY:
$crmField = $this->client->getInstance()->dealProperties()->get($field->crm_provider_id);
break;
case Field::OBJECT_TASK:
$this->syncSingleTaskField($field);
return;
default:
return;
}
$this->syncFieldAction->execute($field, $crmField->toArray());
}
/**
* @param array<array{
* id:string,
* label:string,
* value?:string
* }> $options
*
* @throws CrmException
*
* @return FieldData[]
*
*/
public function importPicklistValues(
Field $field,
array $options = [['id' => '', 'label' => '', 'value' => '']],
): array {
if (! empty($options[0]['id']) || ! empty($options[0]['value'])) {
// We already have the options, no need to fetch them again
return $this->importOptions($field, $options);
}
$options = [];
switch ($field->getObjectType()) {
case Field::OBJECT_ACCOUNT:
$options = $this->getClient()->fetchPropertyOptions('company', $field->getCrmProviderId());
break;
case Field::OBJECT_CONTACT:
$options = $this->getClient()->fetchPropertyOptions('contact', $field->getCrmProviderId());
break;
case Field::OBJECT_OPPORTUNITY:
// Hubspot has different endpoint for stages
$options = $this->getClient()->fetchOpportunityFieldOptions($field);
break;
case Field::OBJECT_TASK:
if ($field->getCrmProviderId() === 'disposition') {
$options = $this->getClient()->fetchDispositionFieldOptions();
} elseif (in_array($field->getCrmProviderId(), ['meetingOutcome', 'activityType'])) {
$options = $this->getClient()->fetchMeetingOutcomeFieldOptions($field);
}
break;
default:
$this->logger->warning('Invalid object type', [
'object_type' => $field->getObjectType(),
'field_id' => $field->getId(),
]);
throw new CrmException('Invalid object type');
}
return $this->importOptions($field, $options);
}
/**
* @inheritdoc
*/
public function importStages(?array $types = null, ?string $missingStageName = null): ?Stage
{
$missingStage = null;
try {
// Use the HubSpot API client instead of the SDK crmPipelines() method
$endpoint = self::getDealsPipelinesEndpoint();
$pipelinesResponse = $this->client->getInstance()->getClient()->request('GET', $endpoint);
$pipelines = $pipelinesResponse->data->results;
} catch (RequestException|BadRequest $exception) {
throw $exception;
}
foreach ($pipelines as $pipeline) {
$stages = [];
// We create a business process to contain the pipeline, and store all stages against it.
$p = ResponseNormalize::normalizePipeline($pipeline);
// Create/update business process for this pipeline
$businessProcess = $this->config->businessProcesses()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'type' => BusinessProcess::TYPE_OPPORTUNITY,
'is_selectable' => $p['active'],
]);
// A record type is really a clone of the business process, used to store which record uses which pipeline.
// Create/update record type clone
$this->config->recordTypes()->updateOrCreate([
'crm_provider_id' => $p['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($p['label'], 0, 150),
'is_selectable' => $p['active'],
'business_process_id' => $businessProcess->id ?? null,
]);
// Stages - fetch all existing stages upfront to avoid N+1 queries
$existingStages = $this->config->stages()
->withTrashed()
->where('type', Stage::TYPE_OPPORTUNITY)
->get()
->keyBy('crm_provider_id');
foreach ($p['stages'] as $dealStage) {
$s = ResponseNormalize::normalizeDealStage($dealStage);
/** @var ?Stage $existingStage */
$existingStage = $existingStages->get($s['id']);
// Restore soft-deleted stages that are now active in HubSpot
if ($existingStage?->trashed() && $s['active']) {
$existingStage->restore();
}
// Upsert stage (updates soft-deleted records without restoring them)
$stage = $this->config->stages()->withTrashed()->updateOrCreate([
'crm_provider_id' => $s['id'],
], [
'team_id' => $this->team->id,
'name' => mb_strimwidth($s['label'], 0, 50),
'label' => mb_strimwidth($s['label'], 0, 191),
'type' => Stage::TYPE_OPPORTUNITY,
'sequence' => $s['displayOrder'],
'is_selectable' => $s['active'],
'probability' => $s['probability'] * 100,
]);
if ($missingStageName === $s['id']) {
$missingStage = $stage;
}
$stages[] = $stage->id;
}
$businessProcess->stages()->sync($stages);
}
return $missingStage;
}
/**
* @inheritdoc
*/
public function syncOrganization(): void
{
try {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function find(string $name, array $scopes): array
{
$count = $this->limit ?? 20;
$offset = $this->offset ?? 0;
/** @var array<int, array<string, mixed>> */
return Cache::remember(
key: $this->team->getId() . $name . $count . $offset,
ttl: 300,
callback: function () use ($name, $offset, $count): array {
$data = [];
// Use the new V3 API to find contacts based on additional fields.
foreach (['companies', 'contacts'] as $objectType) {
$endpoint = '[URL_WITH_CREDENTIALS]
*/
public function findOpportunities(?string $crmAccountId, ?string $crmContactId, ?int $userId = null): array
{
$data = [];
$ownerData = [];
$ownerId = null;
if ($crmAccountId === null) {
return $data;
}
if ($userId) {
$profileRepository = app(ProfileRepository::class);
$profile = $profileRepository->findProfileByUserId($this->config, $userId);
$ownerId = $profile instanceof Profile ? $profile->getCrmProviderId() : null;
}
$closedStages = $this->getClosedDealStages();
$payload = $this->payloadBuilder->generateOpportunitiesSearchPayload(
$this->config,
$crmAccountId,
$closedStages,
);
$results = $this->client->getPaginatedData($payload, 'deals');
foreach ($results['results'] as $object) {
$properties = $object['properties'];
$amount = null;
if (empty($properties['amount']) === false) {
$currency = $properties['deal_currency_code'] ?? $this->config->default_currency;
// Values can contain commas and any junk so strip them.
$value = (float) preg_replace('/[^\d.]/', '', $properties['amount']);
$amount = formatCurrency($value, $currency);
}
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
if ($businessProcess === null) {
// Import it.
$stage = $this->importStages([Stage::TYPE_OPPORTUNITY], $properties['dealstage']);
$businessProcess = $this->config
->businessProcesses()
->where('crm_provider_id', $properties['pipeline'])
->first();
} else {
$stage = $businessProcess
->stages()
->where('crm_provider_id', $properties['dealstage'])
->where('type', Stage::TYPE_OPPORTUNITY)
->first();
if ($stage === null) {
// Import it.
$stage = $this->importStages(null, $properties['dealstage']);
}
}
$recordType = null;
if ($businessProcess) {
$recordType = $businessProcess->recordTypes()->first();
}
$isWon = in_array($properties['dealstage'], $closedStages['won']);
$isLost = in_array($properties['dealstage'], $closedStages['lost']);
$record = [
'crmId' => $object['id'],
'name' => $properties['dealname'] ?? 'Unknown Deal',
'value' => $amount,
'won' => $isWon,
'closed' => $isWon || $isLost,
'stage' => [
'id' => $stage?->getUuid() ?? '',
'name' => $stage?->getName() ?? '',
],
];
if ($recordType) {
$record += [
'recordType' => [
'id' => $recordType->id_string,
'name' => $recordType->name,
],
];
}
if ($ownerId && isset($properties['hubspot_owner_id']) && $properties['hubspot_owner_id'] === $ownerId) {
$ownerData[] = $record;
}
$data[] = $record;
}
if (! empty($ownerData)) {
return $ownerData;
}
return $data;
}
/**
* @inheritdoc
*/
public function getTasks(?string $objectType, string $objectId, ?string $opportunityId): array
{
$data = [];
switch ($objectType) {
case 'contact':
$hsObject = 'contact';
break;
case 'account':
$hsObject = 'company';
break;
default:
// This is a hack to prioritise and override a contact/company with a deal.
if ($opportunityId) {
$hsObject = 'deal';
$objectId = $opportunityId;
} else {
throw new InvalidArgumentException('Object type not supported.');
}
}
$engagementTypes = ['meetings', 'tasks'];
foreach ($engagementTypes as $engagementType) {
$payload = $this->payloadBuilder->getLinkToTaskPayload($hsObject, $objectId, $engagementType);
$this->logger->info('[HubSpot] CRM Search requested', [
'request' => $payload,
]);
$engagements = $this->client->getPaginatedData($payload, $engagementType);
foreach ($engagements['results'] as $engagement) {
if ($engagementType == 'meetings') {
$title = $engagement['properties']['hs_meeting_title'] ?? 'Scheduled meeting';
} elseif ($engagementType == 'tasks') {
$title = $engagement['properties']['hs_task_subject'];
} else {
$title = 'Scheduled meeting';
}
$data[] = [
'crmId' => $engagement['id'],
'subject' => $title,
'due' => $engagement['properties']['hs_timestamp'],
'type' => $engagement['properties']['hs_activity_type'] ?? null,
];
}
}
usort($data, function ($item1, $item2) {
return $item2['due'] <=> $item1['due'];
});
return $data;
}
/**
* Try to find CRM Objects using email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchExactlyByEmail(string $email, ?int $userId = null): ?array
{
$contactProperties = [
'email',
'firstname',
'lastname',
'country',
'phone',
'mobilephone',
'jobtitle',
'hubspot_owner_id',
'associatedcompanyid',
'photo',
];
$contact = null;
$account = null;
try {
$hsContact = $this->getClient()->getContactByEmail($email, $contactProperties);
if ($hsContact) {
$contact = $this->importContact($hsContact);
$account = $contact->account;
}
$data = $this->convertCrmData($contact, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
} catch (BadRequest $e) {
$this->logger->warning('[HubSpot] Search failed', [
'team_id' => $this->team->getId(),
'search_identifier' => $email,
'reason' => $e->getMessage(),
]);
}
return null;
}
public function getDomain(string $email): ?string
{
return $this->getDomainFromEmail($email);
}
/**
* Try to find CRM objects using domain name of the email address
*
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByDomain(string $domain, ?int $userId = null): ?array
{
$companyName = $domain;
// Try to find a company matching their email domain.
$companyProperties = [
'country',
'phone',
'name',
'hs_avatar_filemanager_key',
'industry',
'hubspot_owner_id',
'domain',
];
try {
$hsAccounts = $this->client
->getInstance()
->companies()
->searchByDomain($companyName, $companyProperties);
} catch (Throwable $e) {
$this->logger->info('[HubSpot] Search failed', [
'error' => $e->getMessage(),
'domain' => $domain,
]);
return null;
}
$account = null;
// If there are multiple accounts, don't guess, we'll ask later.
if (\count($hsAccounts->data->results) === 1) {
// Persist this remote object.
$account = $this->syncAccount($hsAccounts->data->results[0]->companyId);
}
$data = $this->convertCrmData(null, $account, $userId);
return ! empty(array_filter($data)) ? $data : null;
}
/**
* @return array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
protected function convertCrmData(?Contact $contact, ?Account $account, ?int $userId = null): array
{
$countryCode = null;
if ($contact && $contact->country_code) {
$countryCode = $contact->country_code;
} elseif ($account && $account->country_code) {
$countryCode = $account->country_code;
}
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact ? $contact->crm_provider_id : null,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
// If there are multiple opportunities, don't guess, we'll ask later.
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
protected function getCacheKey(string $object, ?int $userId = null): ?string
{
$key = $this->team->getId() . $object;
$keySuffix = $this->getOwnerKeySuffix($userId);
return $key . $keySuffix;
}
private function getOwnerKeySuffix(?int $userId = null): string
{
return $userId === null ? '' : (string) $userId;
}
/**
* @return null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
*}
*/
public function matchByPhone(string $phone, ?string $rawPhoneNumber = null, ?int $userId = null): ?array
{
if (str_contains($phone, '**')) {
return null;
}
// trim all whitespaces if present so the lookup doesn't fail
$phone = str_replace(' ', '', $phone);
// Check if the user is internal.
if ($this->isPhoneNumberOfTeamMember($phone)) {
return null;
}
$response = $this->searchForPhoneNumber($phone);
if (empty($response)) {
return null;
}
// This would ideally importContact instead but the response type differs.
$contact = $this->findAndSyncContact($response['results'][0]['id']);
if (! $contact instanceof Contact) {
return null;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account?->crm_provider_id,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
try {
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
} catch (Exception $e) {
$this->logger->debug('[HubSpot] Opportunity failed to sync.', [
'reason' => $e->getMessage(),
]);
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
}
private function isPhoneNumberOfTeamMember(string $phone): bool
{
$teamRepository = app(TeamRepository::class);
$user = $teamRepository->findTeamMemberByPhone($this->team, $phone);
if ($user instanceof User) {
return true;
}
return false;
}
private function findAndSyncContact(string $crmId): ?Contact
{
try {
return $this->syncContact($crmId);
} catch (Exception $exception) {
$this->logger->info('[HubSpot] Phone match failed', [
'reason' => $exception->getMessage(),
]);
return null;
}
}
private function hasResults(array $response): bool
{
return isset($response['total']) && is_numeric($response['total']) && $response['total'] > 0;
}
private function searchForPhoneNumber(string $phone): array
{
// Normalizes the provided phone number for the API search.
$normalizedPhone = $this->normalizePhoneNumber($phone);
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone);
$this->logger->info('[HubSpot] Phone match search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($normalizedPhone, $payload);
if (! $this->hasResults($response)) {
$nationalPhone = preg_replace('/\D/', '', phone_national(null, $phone));
$payload = $this->payloadBuilder->generatePhoneSearchPayload($nationalPhone);
$this->logger->info('[HubSpot] Phone match national number search triggered', [
'phone' => $phone,
'nationalPhone' => $nationalPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
if (! $this->hasResults($response)) {
$payload = $this->payloadBuilder->generatePhoneSearchPayload($normalizedPhone, true);
$this->logger->info('[HubSpot] Phone match alternative search triggered', [
'phone' => $phone,
'normalizedPhone' => $normalizedPhone,
'payload' => $payload,
]);
$response = $this->handlePhoneSearchRequest($phone, $payload);
}
return $this->hasResults($response) ? $response : [];
}
private function handlePhoneSearchRequest(string $phone, array $payload): array
{
$endpoint = '[URL_WITH_CREDENTIALS] null|array{
* Lead|null,
* Account|null,
* Opportunity|null,
* Contact|null,
* Stage|null,
* string|null
* }
*/
public function matchByName(string $name, ?int $userId = null): ?array
{
// Don't waste time searching for single character strings.
if (\strlen($name) <= 1) {
return null;
}
$cacheKey = $this->getCacheKey($name, $userId);
$result = Cache::remember($cacheKey, 60, function () use ($name, $userId) {
$payload = $this->payloadBuilder->generateSearchContactsByNamePayload(
$name,
$this->getContactFields()
);
$hsContacts = $this->client->getPaginatedData($payload, 'contact');
if (empty($hsContacts['results'])) {
return false;
}
$contact = $this->importContact($hsContacts['results'][0]);
if ($contact === null) {
return false;
}
$account = $contact->account;
$countryCode = $contact->country_code ?? $account->country_code ?? null;
try {
$hsOpportunities = $this->findOpportunities(
$account ? $account->crm_provider_id : null,
$contact->crm_provider_id,
$userId
);
} catch (Exception $e) {
$hsOpportunities = [];
}
$opportunity = null;
$stage = null;
if (! empty($hsOpportunities)) {
// Persist this remote object.
$opportunity = $this->syncOpportunity($hsOpportunities[0]['crmId']);
$stage = $opportunity?->getStage();
}
return [
null,
$account,
$opportunity,
$contact,
$stage,
$countryCode,
];
});
return is_array($result) ? $result : null;
}
private function convertActivityAssociations(Activity $activity): array
{
return [
'contactIds' => $this->getParticipantsIds($activity),
'companyIds' => $activity->hasAccount() ? [$activity->account->crm_provider_id] : [],
'dealIds' => $activity->hasOpportunity() ? [$activity->opportunity->crm_provider_id] : [],
'ownerIds' => [],
];
}
private function getParticipantsIds(Activity $activity): array
{
$attendees = [];
$participantRepository = app(ParticipantRepository::class);
$participants = $participantRepository->getParticipantsWhoEnteredMeeting($activity);
foreach ($participants as $participant) {
if ($participant->user_id || $participant->isCoach()) {
continue;
}
$contact = $participant->contact()->first();
if ($contact && $contact->crm_provider_id) {
$attendees[] = $contact->crm_provider_id;
} else {
if (! empty($participant->name)) {
$attendeeData = $this->fetchMissingAttendeeInfo($participant);
}
if (! empty($attendeeData['id'])) {
$attendees[] = $attendeeData['id'];
}
}
}
if ($activity->hasContact()) {
$attendees[] = $activity->contact->crm_provider_id;
}
return array_unique($attendees);
}
private function fetchMissingAttendeeInfo(Participant $participant): array
{
// Check if we need to look inside an account context.
$activity = $participant->getActivity();
$companyId = $activity->hasAccount() ? $activity->getAccount()->crm_provider_id : null;
// First check the local data.
/** @var Contact[] $contacts */
$contacts = $this->team->contacts()
->with('account')
->where('name', $participant->name)
->whereNotNull('email')
->get();
foreach ($contacts as $contact) {
// If we have a company in scope, check the contact is associated to it.
if (
$companyId !== null
&& ($contact->account_id === null || $companyId !== $contact->account->crm_provider_id)
) {
continue;
}
return [
'id' => $contact->crm_provider_id,
'email' => $contact->email,
];
}
$payload = $this->generateNameSearchPayload($participant->name, 0, 20);
try {
$response = $this->client->getNewInstance()->crm()->contacts()->searchApi()->doSearch($payload);
// TODO add some logic to choose the most suitable contact if multiple
foreach ($response['results'] as $object) {
$properties = $object['properties'];
if (empty($object['properties']) === false) {
// Check the company matches the contact.
// Todo: Move this check inside the API search.
if ($companyId !== null && $companyId !== $properties['associatedcompanyid']) {
continue;
}
return [
'id' => $object['id'],
'email' => $properties['email'],
];
}
}
} catch (Exception $e) {
$this->logger->warning('[' . $this->getDisplayName() . '] Search failed', [
'teamId' => $this->team->id_string,
'request' => $payload,
'reason' => $e->getMessage(),
]);
}
return [];
}
/**
* Store transcripts as note engagement.
*
* @throws Exception
*/
public function createTranscriptNotes(Activity $activity): void
{
// For HS no need to check if Crm profile - Log Notes field is enabled
// We only check if store_transcript toggle is enabled on crm profile.
$engagement = [
'active' => true,
'ownerId' => $this->profile->crm_provider_id,
'timestamp' => $activity->created_at->tz($activity->user->timezone)->getTimestamp() * 1000,
'type' => 'NOTE',
];
// Generate activity transcription.
$transcriptionData = $this->generateTranscription($activity);
// Truncate Notes with max notes length because transcription text could be very long.
$transcripts = mb_strimwidth($transcriptionData, 0, static::ENGAGEMENT_BODY_MAX_LENGTH);
$metadata = [
'body' => $transcripts,
];
$associations = $this->convertActivityAssociations($activity);
try {
$hsEngagement = $this->client
->getInstance()
->engagements()
->create($engagement, $associations, $metadata);
$this->logCrmEngagementManipulation(self::ACTION_CREATE, $engagement, $metadata, $associations);
$noteId = $hsEngagement->data->engagement->id;
// Store crm logged id in transcription.
$transcription = $activity->getTranscription();
$transcription->crm_activity_id = $noteId;
$transcription->save();
} catch (Exception $e) {
Sentry::captureException($e);
}
}
/*
* @inheritdoc
*/
public function updateRecord(string $objectType, string $objectId, array $data, array $headers = []): void
{
$payload = [
'properties' => $data,
];
try {
switch ($objectType) {
case FieldData::OBJECT_OPPORTUNITY:
$this->client->getNewInstance()->crm()->deals()->basicApi()->update($objectId, $payload);
break;
case FieldData::OBJECT_CONTACT:
$this->client->getNewInstance()->crm()->contacts()->basicApi()->update($objectId, $payload);
b...
|
NULL
|
NULL
|
NULL
|
NULL
|